From 0fa85ed95a14e551ae5e5d6dcc127e34fc0e8387 Mon Sep 17 00:00:00 2001 From: Nathaniel Paulus Date: Fri, 19 Jun 2026 15:18:32 -0400 Subject: [PATCH 01/10] SF-3817 Partial book drafting --- .../scriptureforge/models/translate-config.ts | 2 + .../ClientApp/e2e/characterize-tests.mts | 2 +- .../ClientApp/e2e/test-definitions.ts | 4 + .../ClientApp/e2e/test_characterization.json | 4 + .../test_data/partial_draft_training_1.tsv | 4 + .../test_data/partial_draft_training_2.tsv | 4 + .../e2e/workflows/partial-book-drafting.ts | 406 ++++++ .../ClientApp/src/app/app.component.html | 5 + .../ClientApp/src/app/app.component.ts | 7 + .../ClientApp/src/app/app.routes.ts | 6 + .../src/app/core/sf-project.service.ts | 12 +- .../serval-build-report.ts | 5 +- .../serval-builds.component.ts | 20 +- .../book-multi-select.component.html | 66 +- .../book-multi-select.component.spec.ts | 21 +- .../book-multi-select.component.ts | 36 +- .../progress-service/progress.service.spec.ts | 111 +- .../progress-service/progress.service.ts | 83 +- .../src/app/shared/scripture-range.spec.ts | 188 +++ .../src/app/shared/scripture-range.ts | 255 ++++ .../ClientApp/src/app/shared/utils.spec.ts | 15 +- .../ClientApp/src/app/shared/utils.ts | 26 +- .../confirm-sources.component.scss | 3 +- .../draft-generation-steps.component.ts | 35 +- .../draft-generation.component.spec.ts | 5 +- .../draft-generation.component.ts | 12 +- .../draft-generation.service.spec.ts | 69 + .../draft-generation.service.ts | 5 + .../draft-generation/draft-generation.ts | 6 + .../draft-import-wizard.component.spec.ts | 65 + .../draft-import-wizard.component.ts | 42 +- .../draft-preview-books.component.html | 3 + .../draft-preview-books.component.spec.ts | 29 + .../draft-preview-books.component.ts | 67 +- .../draft-onboarding-form.component.html | 1 - .../draft-generation/draft-source.ts | 19 + .../draft-sources.service.spec.ts | 25 +- .../draft-generation/draft-sources.service.ts | 5 +- .../draft-pending-updates.component.html | 47 + .../draft-pending-updates.component.scss | 45 + .../draft-pending-updates.component.spec.ts | 477 +++++++ ...draft-pending-updates.component.stories.ts | 204 +++ .../draft-pending-updates.component.ts | 162 +++ .../new-draft/new-draft-logic-handler.spec.ts | 1147 ++++++++++++++++ .../new-draft/new-draft-logic-handler.ts | 699 ++++++++++ .../new-draft/new-draft.component.html | 367 ++++++ .../new-draft/new-draft.component.scss | 193 +++ .../new-draft/new-draft.component.spec.ts | 1157 +++++++++++++++++ .../new-draft/new-draft.component.stories.ts | 505 +++++++ .../new-draft/new-draft.component.ts | 916 +++++++++++++ .../training-data-file-selection.spec.ts | 65 + .../new-draft/training-data-file-selection.ts | 33 + .../new-draft/training-data-summary.spec.ts | 103 ++ .../new-draft/training-data-summary.ts | 82 ++ .../src/assets/i18n/non_checking_en.json | 85 ++ ...xperimental-features-dialog.component.html | 24 + ...xperimental-features-dialog.component.scss | 40 + ...rimental-features-dialog.component.spec.ts | 142 ++ .../experimental-features-dialog.component.ts | 34 + .../experimental-features.service.ts | 53 + .../feature-flags/feature-flag.service.ts | 7 + .../src/xforge-common/i18n.service.ts | 13 +- .../src/xforge-common/util/set-util.spec.ts | 53 + .../src/xforge-common/util/set-util.ts | 34 + .../Models/BookProgress.cs | 20 + .../Models/BuildConfig.cs | 11 + .../Models/DraftConfig.cs | 7 + .../Services/BuildConfigJsonConverter.cs | 6 + .../Services/MachineApiService.cs | 10 + .../Services/MachineProjectService.cs | 67 +- .../Services/SFProjectService.cs | 28 + .../Services/BuildConfigJsonConverterTests.cs | 74 ++ .../Services/MachineApiServiceTests.cs | 26 + .../Services/MachineProjectServiceTests.cs | 63 + 74 files changed, 8460 insertions(+), 212 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_1.tsv create mode 100644 src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_2.tsv create mode 100644 src/SIL.XForge.Scripture/ClientApp/e2e/workflows/partial-book-drafting.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/shared/scripture-range.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.stories.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features.service.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.ts diff --git a/src/RealtimeServer/scriptureforge/models/translate-config.ts b/src/RealtimeServer/scriptureforge/models/translate-config.ts index d1f4579e85d..541c78b9276 100644 --- a/src/RealtimeServer/scriptureforge/models/translate-config.ts +++ b/src/RealtimeServer/scriptureforge/models/translate-config.ts @@ -74,6 +74,8 @@ export interface DraftConfig { draftingSources: TranslateSource[]; trainingSources: TranslateSource[]; lastSelectedTrainingDataFiles: string[]; + /** Training data files available at the last build, to distinguish newly added files from deliberately deselected ones. */ + lastAvailableTrainingDataFiles?: string[]; lastSelectedTrainingScriptureRanges?: ProjectScriptureRange[]; lastSelectedTranslationScriptureRanges?: ProjectScriptureRange[]; fastTraining?: boolean; diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/characterize-tests.mts b/src/SIL.XForge.Scripture/ClientApp/e2e/characterize-tests.mts index 0c6ae3d57aa..89ffe98f982 100755 --- a/src/SIL.XForge.Scripture/ClientApp/e2e/characterize-tests.mts +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/characterize-tests.mts @@ -6,7 +6,7 @@ import { numberOfTimesToAttemptTest } from './pass-probability.ts'; import { ScreenshotContext } from './presets.ts'; import { tests } from './test-definitions.ts'; -const retriesToStopAt = 3; // Stop characterization after a test is reliable enough to only need this many retries +const retriesToStopAt = 4; // Stop characterization after a test is reliable enough to only need this many retries const resultFilePath = 'test_characterization.json'; const testNames = Object.keys(tests) as (keyof typeof tests)[]; let mostRecentResultData = JSON.parse(await Deno.readTextFile(resultFilePath)); diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/test-definitions.ts b/src/SIL.XForge.Scripture/ClientApp/e2e/test-definitions.ts index 11725b76592..b075103cd3d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/e2e/test-definitions.ts +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/test-definitions.ts @@ -6,6 +6,7 @@ import { editTranslation } from './workflows/edit-translation.ts'; import { generateDraft } from './workflows/generate-draft.ts'; import { localizedScreenshots } from './workflows/localized-screenshots.ts'; import { onboardingFlow } from './workflows/onboarding-flow.ts'; +import { partialBookDrafting } from './workflows/partial-book-drafting.ts'; import { runSmokeTests, traverseHomePageAndLoginPage } from './workflows/smoke-tests.mts'; export const tests = { @@ -24,6 +25,9 @@ export const tests = { generate_draft: async (_engine: BrowserType, page: Page, screenshotContext: ScreenshotContext) => { await generateDraft(page, screenshotContext, secrets.users[0]); }, + partial_book_drafting: async (_engine: BrowserType, page: Page, screenshotContext: ScreenshotContext) => { + await partialBookDrafting(page, screenshotContext, secrets.users[0]); + }, onboarding_flow: async (engine: BrowserType, page: Page, screenshotContext: ScreenshotContext) => { await onboardingFlow(engine, page, screenshotContext, secrets.users[0]); }, diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/test_characterization.json b/src/SIL.XForge.Scripture/ClientApp/e2e/test_characterization.json index 4d8d2a4e41b..445fe7eb130 100644 --- a/src/SIL.XForge.Scripture/ClientApp/e2e/test_characterization.json +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/test_characterization.json @@ -26,5 +26,9 @@ "onboarding_flow": { "success": 7, "failure": 0 + }, + "partial_book_drafting": { + "success": 7, + "failure": 0 } } diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_1.tsv b/src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_1.tsv new file mode 100644 index 00000000000..070da6d5de2 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_1.tsv @@ -0,0 +1,4 @@ +Source Target +In the beginning God created the heavens and the earth. En el principio creó Dios los cielos y la tierra. +And God said, Let there be light: and there was light. Y dijo Dios: Sea la luz; y fue la luz. +And God called the light Day, and the darkness he called Night. Y llamó Dios a la luz Día, y a las tinieblas llamó Noche. diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_2.tsv b/src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_2.tsv new file mode 100644 index 00000000000..93f688af826 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/test_data/partial_draft_training_2.tsv @@ -0,0 +1,4 @@ +Source Target +Now the serpent was more subtil than any beast of the field. Pero la serpiente era astuta, más que todos los animales del campo. +And they heard the voice of the LORD God walking in the garden. Y oyeron la voz de Jehová Dios que se paseaba en el huerto. +And the LORD God called unto Adam, and said unto him, Where art thou? Mas Jehová Dios llamó al hombre, y le dijo: ¿Dónde estás tú? diff --git a/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/partial-book-drafting.ts b/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/partial-book-drafting.ts new file mode 100644 index 00000000000..1c5212c9322 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/e2e/workflows/partial-book-drafting.ts @@ -0,0 +1,406 @@ +import { expect } from 'npm:@playwright/test'; +import { Locator, Page } from 'npm:playwright'; +import { E2E_SYNC_DEFAULT_TIMEOUT, preset, ScreenshotContext } from '../e2e-globals.ts'; +import { + enableDeveloperMode, + enableDraftingOnProjectAsServalAdmin, + enableFeatureFlag, + freshlyConnectProject, + getNewBrowserForSideWork, + installMouseFollower, + logInAsPTUser, + logInAsSiteAdmin, + logOut, + screenshot, + switchLanguage +} from '../e2e-utils.ts'; +import { UserEmulator } from '../user-emulator.mts'; + +type EngineMode = 'echo' | 'fast'; + +const ENGINE_MODE: EngineMode = 'echo'; + +const TARGET_PROJECT_SHORT_NAME = 'SFDDP'; +const DRAFTING_SOURCE = { shortName: 'ntv', optionName: 'NTV - Nueva Traducción' }; +const SECOND_REFERENCE = { shortName: 'dhh94', optionName: 'DHH94 - Spanish: Dios Habla' }; + +const PARTIAL_BOOK_NAME = 'Genesis'; +const DRAFT_CHAPTERS = '30-32'; +const FIRST_DRAFTED_CHAPTER = 30; +const AN_UNDRAFTED_CHAPTER = 33; + +// Whole books we train on from the (complete) New Testament, to verify training-book persistence on return. +const TRAINING_NT_BOOKS = ['Matthew', 'Mark']; +// A book we deliberately leave UNSELECTED in run 1. On return it must still be unselected. +const UNSELECTED_TRAINING_BOOK = 'Luke'; + +const TRAINING_FILES = [ + { path: 'test_data/partial_draft_training_1.tsv', title: 'partial_draft_training_1' }, + { path: 'test_data/partial_draft_training_2.tsv', title: 'partial_draft_training_2' } +]; +// Deselected in the wizard; verified as still-deselected on return. +const DESELECTED_FILE = TRAINING_FILES[1]; + +export async function partialBookDrafting( + page: Page, + context: ScreenshotContext, + credentials: { email: string; password: string } +): Promise { + await logInAsPTUser(page, credentials); + await switchLanguage(page, 'en'); + if (preset.showArrow) await installMouseFollower(page); + await page.waitForTimeout(500); + const user = new UserEmulator(page); + + await freshlyConnectProject(page, TARGET_PROJECT_SHORT_NAME); + + await enableDeveloperMode(page, { closeMenu: true }); + await enableFeatureFlag(page, 'Partial book drafting'); + + await user.click(page.getByRole('link', { name: 'Generate draft' })); + await expect(page.getByRole('heading', { name: 'Generate translation drafts' })).toBeVisible(); + + // Have a Serval admin enable pre-translation drafting on the project + const siteAdminBrowser = await getNewBrowserForSideWork(); + await logInAsSiteAdmin(siteAdminBrowser.page); + await enableDraftingOnProjectAsServalAdmin(siteAdminBrowser.page, TARGET_PROJECT_SHORT_NAME); + await siteAdminBrowser.browser.close(); + + await configureSources(page, user, context); + + // Launch a partial-book draft + await user.click(page.getByRole('button', { name: 'Generate draft' })); + await stepThroughPreface(page, user, context); + await selectPartialDraftBook(page, user, context); + await selectTrainingData(page, user, context); + await reviewSummaryAndGenerate(page, user, context); + + await waitForDraftToComplete(page, context); + // Verify what was actually drafted + await importDraft(page, user, context); + await verifyDraftedChaptersInEditor(page, user); + + // Return to the wizard and verify remembered selections + await verifyRememberedSelectionsOnReturn(page, user, context); + + await logOut(page); +} + +/** Configure the drafting source, two reference projects, and upload two training-data files, then save & sync. */ +async function configureSources(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { + await user.click(page.getByRole('button', { name: 'Configure sources' })); + + const trainingDataSection = page.locator('mat-card').nth(0); + const translationDataSection = page.locator('mat-card').nth(1); + + await user.click(trainingDataSection.getByRole('combobox').first()); + await user.type(DRAFTING_SOURCE.shortName); + await user.click(page.getByRole('option', { name: DRAFTING_SOURCE.optionName })); + await user.click(page.getByRole('button', { name: 'Add another reference project' })); + await user.click(trainingDataSection.getByRole('combobox').nth(1)); + await user.type(SECOND_REFERENCE.shortName); + await user.click(page.getByRole('option', { name: SECOND_REFERENCE.optionName })); + + // Fixtures have a header row; skip it. + for (const file of TRAINING_FILES) { + await user.click(page.getByRole('button', { name: 'Upload spreadsheet' })); + await page.locator('mat-dialog-container input[type="file"]').setInputFiles(file.path); + await page.getByRole('checkbox', { name: 'Skip first row of data file' }).check(); + await page.locator('#upload-save-btn').click(); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); + } + + await user.click(translationDataSection.getByRole('combobox')); + await user.type(DRAFTING_SOURCE.shortName); + await user.click(page.getByRole('option', { name: DRAFTING_SOURCE.optionName })); + + await user.check(page.getByRole('checkbox', { name: 'All the language codes are correct' })); + await screenshot(page, { pageName: 'partial_draft_configure_sources', ...context }); + await user.click(page.locator('#save_button')); + + // Saving/syncing can take several minutes on first sync. + const closeLocator = page.getByRole('button', { name: 'Close' }); + await expect(closeLocator).toBeVisible({ timeout: 5 * 60_000 }); + await user.click(closeLocator); +} + +/** The pre-step (pending updates) may or may not appear; then the read-only source-confirmation preface. */ +async function stepThroughPreface(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { + const continueAnyway = page.getByRole('button', { name: 'Continue anyway' }); + if (await continueAnyway.isVisible().catch(() => false)) { + await user.click(continueAnyway); + } + + await expect(page.locator('app-confirm-sources')).toBeVisible(); + await expect(page.locator('app-confirm-sources')).toContainText(DRAFTING_SOURCE.shortName.toUpperCase()); + await screenshot(page, { pageName: 'partial_draft_preface', ...context }); + await user.click(page.getByRole('button', { name: 'Next' })); +} + +/** Select Genesis, assert the untranslated-tail default, exercise validation, then narrow to a known sub-range. */ +async function selectPartialDraftBook(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { + await expect(page.getByRole('option', { name: PARTIAL_BOOK_NAME, exact: true })).toBeVisible(); + await user.click(page.getByRole('option', { name: PARTIAL_BOOK_NAME, exact: true })); + + const chapterInput = page.locator('.partial-book-drafting-table .chapter-input input'); + await expect(chapterInput).toBeVisible(); + + // Default = chapters in the source but not the target (the untranslated chapters). + const defaultRange = await chapterInput.inputValue(); + console.log(`Default drafting chapter range for ${PARTIAL_BOOK_NAME}: "${defaultRange}"`); + expect(defaultRange).toMatch(/-50$/); + expect(Number.parseInt(defaultRange)).toBeGreaterThan(1); + + await chapterInput.fill('23-21'); + await chapterInput.blur(); + await expect(page.locator('.partial-book-drafting-table .chapter-error')).toBeVisible(); + + await chapterInput.fill(DRAFT_CHAPTERS); + await chapterInput.blur(); + await expect(page.locator('.partial-book-drafting-table .chapter-error')).toHaveCount(0); + await expect(chapterInput).toHaveValue(DRAFT_CHAPTERS); + + await screenshot(page, { pageName: 'partial_draft_select_books', ...context }); + await user.click(page.getByRole('button', { name: 'Next' })); +} + +/** On the training step: pick target training NT books, per-source books, and deselect one training-data file. */ +async function selectTrainingData(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { + // Pick a specific NT subset and leave UNSELECTED_TRAINING_BOOK untouched to verify both-ways persistence on return. + const bookSelects = page.locator('app-book-multi-select'); + const targetBookSelect = bookSelects.first(); + for (const book of TRAINING_NT_BOOKS) { + await user.click(targetBookSelect.getByRole('option', { name: book, exact: true })); + } + + // Selecting a target book auto-pairs it in each training source; verify auto-pairing happened in every source. + const sourceCount = await bookSelects.count(); + expect(sourceCount).toBeGreaterThan(1); // target + at least one training source + for (let i = 1; i < sourceCount; i++) { + for (const book of TRAINING_NT_BOOKS) { + await expectBookSelected(bookSelects.nth(i), book, true); + } + } + + // Deselecting an auto-paired book from one source is allowed while the other source still covers it. + const toggledBook = TRAINING_NT_BOOKS[TRAINING_NT_BOOKS.length - 1]; + const firstSourceSelect = bookSelects.nth(1); + await user.click(firstSourceSelect.getByRole('option', { name: toggledBook, exact: true })); + await expectBookSelected(firstSourceSelect, toggledBook, false); + await user.click(firstSourceSelect.getByRole('option', { name: toggledBook, exact: true })); + await expectBookSelected(firstSourceSelect, toggledBook, true); + + // Deselect the second training-data file; both start selected by default for a first build. + const fileCheckbox = page.locator('.training-data-files').getByRole('checkbox', { name: DESELECTED_FILE.title }); + await expect(fileCheckbox).toBeChecked(); + await user.click(fileCheckbox); + await expect(fileCheckbox).not.toBeChecked(); + + await screenshot(page, { pageName: 'partial_draft_training_data', ...context }); + await user.click(page.getByRole('button', { name: 'Next' })); +} + +/** The summary should reflect the partial book selection; set the engine and launch. */ +async function reviewSummaryAndGenerate(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { + await expect(page.locator('.draft-books-list')).toContainText(`${PARTIAL_BOOK_NAME} (${DRAFT_CHAPTERS})`); + + if (ENGINE_MODE === 'echo') { + await user.check(page.getByRole('checkbox', { name: 'Echo Translation Engine' })); + } else if (ENGINE_MODE === 'fast') { + await user.check(page.getByRole('checkbox', { name: 'Fast Training' })); + } + + await screenshot(page, { pageName: 'partial_draft_summary', ...context }); + await user.click(page.getByRole('button', { name: 'Generate Draft' })); + console.log('Partial draft started'); +} + +/** Wait for the draft to complete, with stall detection. */ +async function waitForDraftToComplete(page: Page, context: ScreenshotContext): Promise { + const startTime = Date.now(); + const progressCardHeader = page.locator('.draft-progress-card mat-card-title'); + await expect(progressCardHeader).toContainText(PARTIAL_BOOK_NAME, { timeout: 60_000 }); + + const draftReadyLocator = page.getByRole('heading', { name: 'The draft is ready' }); + const inProgressTimeout = ENGINE_MODE === 'echo' ? 3 * 60_000 : 15 * 60_000; + await expect(page.getByRole('heading', { name: 'Draft in progress' }).or(draftReadyLocator)).toBeVisible({ + timeout: inProgressTimeout + }); + + let progress: number | null = null; + let lastProgressChange: number | null = null; + while (!(await draftReadyLocator.isVisible())) { + const currentProgressText = (await page.locator('circle-progress').allTextContents())[0]; + if (currentProgressText == null) break; + const currentProgress = Number.parseInt(currentProgressText); + if (progress !== currentProgress) { + lastProgressChange = Date.now(); + progress = currentProgress; + } + const progressChangeTimeoutMinutes = 3; + if (lastProgressChange != null && Date.now() - lastProgressChange > 60_000 * progressChangeTimeoutMinutes) { + throw new Error( + `Draft progress stalled at ${progress}% and unchanged in ${progressChangeTimeoutMinutes} minutes.` + ); + } + await page.waitForTimeout(100); + } + + await expect(draftReadyLocator).toBeVisible(); + console.log('Partial draft generation took', ((Date.now() - startTime) / 60_000).toFixed(2), 'minutes'); + await screenshot(page, { pageName: 'partial_draft_completed', ...context }); + + // Reloading triggers the draft-status update and avoids a known freeze on lower-end machines. + await page.reload(); + let finishing: boolean; + try { + await expect(page.getByText('Draft is Finishing')).toBeVisible({ timeout: 15_000 }); + finishing = true; + } catch { + finishing = false; + } + if (finishing) await expect(page.getByText('Draft is Finishing')).not.toBeVisible({ timeout: 15_000 }); +} + +/** Import the completed draft into the target project. */ +async function importDraft(page: Page, user: UserEmulator, context: ScreenshotContext): Promise { + // Formatting options must be chosen before the import actions appear. + await user.click(page.getByRole('button', { name: 'Formatting options' })); + await user.click(page.getByRole('button', { name: 'Save' })); + + await expect( + page.getByRole('button', { name: new RegExp(`${PARTIAL_BOOK_NAME}\\s*\\(${DRAFT_CHAPTERS}\\)`) }) + ).toBeVisible(); + + await user.click(page.getByRole('button', { name: 'Add to a project' })); + await user.click(page.getByRole('combobox', { name: 'Choose a project' })); + await user.type(TARGET_PROJECT_SHORT_NAME); + await user.click(page.getByRole('option', { name: `${TARGET_PROJECT_SHORT_NAME} -` })); + // Without content in the target, this starts the import directly; otherwise it advances to overwrite confirmation. + await user.click(page.locator('[data-test-id="step-1-next"]')); + + // Overwrite confirmation only appears when drafted chapters already have content in the target; handle defensively. + const understand = page.getByRole('checkbox', { name: /I understand that existing content will be overwritten/ }); + if (await understand.isVisible().catch(() => false)) { + await user.check(understand); + await user.click(page.locator('[data-test-id="step-5-next"]')); + } + + await expect(page.getByText('Import complete', { exact: true })).toBeVisible({ timeout: 5 * 60_000 }); + + // Finish the wizard through the sync step, as a real user would, so the imported draft lands in the project. + await user.click(page.locator('[data-test-id="step-6-next"]')); + await user.click(page.locator('[data-test-id="step-7-sync"]')); + await expect(page.getByText(`The draft has been imported into ${TARGET_PROJECT_SHORT_NAME}`)).toBeVisible({ + timeout: E2E_SYNC_DEFAULT_TIMEOUT + }); + await user.click(page.getByRole('button', { name: 'Done' })); + + await screenshot(page, { pageName: 'partial_draft_imported', ...context }); +} + +/** Verify a drafted chapter has content and the chapter after the range is still empty. */ +async function verifyDraftedChaptersInEditor(page: Page, user: UserEmulator): Promise { + await user.click(page.getByRole('link', { name: 'Edit & review' })); + await page.waitForSelector('#sync-icon:not(.sync-in-progress)'); + + await selectBookAndChapter(page, user, PARTIAL_BOOK_NAME, FIRST_DRAFTED_CHAPTER); + await expectEditorOnChapter(page, PARTIAL_BOOK_NAME, FIRST_DRAFTED_CHAPTER); + const draftedSegment = getTargetSegment(page, FIRST_DRAFTED_CHAPTER, 1); + await expect(draftedSegment).toBeVisible(); + expect(((await draftedSegment.textContent()) ?? '').trim().length).toBeGreaterThan(0); + + // Confirm navigation succeeded before asserting emptiness, to rule out a load failure masquerading as an empty chapter. + await selectBookAndChapter(page, user, PARTIAL_BOOK_NAME, AN_UNDRAFTED_CHAPTER); + await expectEditorOnChapter(page, PARTIAL_BOOK_NAME, AN_UNDRAFTED_CHAPTER); + const targetEditor = page.locator('app-tab-group:has(#target) .ql-editor').filter({ visible: true }); + await expect(targetEditor).toBeVisible(); + const verseTexts = await targetEditor.locator(`[data-segment^="verse_${AN_UNDRAFTED_CHAPTER}_"]`).allTextContents(); + const versesWithContent = verseTexts.filter(text => text.trim().length > 0); + expect(versesWithContent).toEqual([]); +} + +async function expectEditorOnChapter(page: Page, book: string, chapter: number): Promise { + const chooser = page.locator('.toolbar app-book-chapter-chooser'); + await expect(chooser.getByRole('combobox').first()).toContainText(book); + await expect(chooser.getByRole('combobox').last()).toContainText(String(chapter)); +} + +/** + * Returning to generate another draft, the wizard should restore the training selections (training source books, + * target training books, and training-data file selection) but NOT the drafting book/chapter selection. + */ +async function verifyRememberedSelectionsOnReturn( + page: Page, + user: UserEmulator, + context: ScreenshotContext +): Promise { + await user.click(page.getByRole('link', { name: 'Generate draft' })); + // After a draft exists, the CTA is labeled "New draft" rather than "Generate draft". + await user.click(page.getByRole('button', { name: 'New draft' })); + + await stepThroughPreface(page, user, context); + + // Drafting selection is intentionally NOT persisted: no book should be pre-selected on return. + await expect(page.getByRole('option', { name: PARTIAL_BOOK_NAME, exact: true })).toBeVisible(); + await expect(page.locator('.partial-book-drafting-table')).toHaveCount(0); + await user.click(page.getByRole('option', { name: PARTIAL_BOOK_NAME, exact: true })); + const chapterInput = page.locator('.partial-book-drafting-table .chapter-input input'); + await chapterInput.fill(DRAFT_CHAPTERS); + await chapterInput.blur(); + await user.click(page.getByRole('button', { name: 'Next' })); + + // Training selections ARE persisted. The books we picked come back selected; the deliberately unselected one stays unselected. + const bookSelects = page.locator('app-book-multi-select'); + const bookSelectCount = await bookSelects.count(); + expect(bookSelectCount).toBeGreaterThan(1); // target + at least one training source + for (let i = 0; i < bookSelectCount; i++) { + const bookSelect = bookSelects.nth(i); + for (const book of TRAINING_NT_BOOKS) { + await expectBookSelected(bookSelect, book, true); + } + } + + await expectBookSelected(bookSelects.first(), UNSELECTED_TRAINING_BOOK, false); + + const fileCheckbox = page.locator('.training-data-files').getByRole('checkbox', { name: DESELECTED_FILE.title }); + await expect(fileCheckbox).not.toBeChecked(); + + await screenshot(page, { pageName: 'partial_draft_remembered_selections', ...context }); +} + +async function expectBookSelected(bookSelect: Locator, book: string, selected: boolean): Promise { + const option = bookSelect.getByRole('option', { name: book, exact: true }); + if (selected) { + await expect(option).toHaveAttribute('aria-selected', 'true'); + } else { + await expect(option).not.toHaveAttribute('aria-selected', 'true'); + } +} + +// Editor helpers (mirrors edit-translation.ts) + +function getTargetSegment(page: Page, chapter: number, verse: number): Locator { + return page + .locator('app-tab-group:has(#target) .ql-editor') + .locator(`[data-segment="verse_${chapter}_${verse}"]`) + .filter({ visible: true }); +} + +async function selectBookAndChapter(page: Page, user: UserEmulator, book: string, chapter: number): Promise { + const bookChapterChooser = page.locator('.toolbar app-book-chapter-chooser'); + const bookChooser = bookChapterChooser.getByRole('combobox').first(); + const chapterChooser = bookChapterChooser.getByRole('combobox').last(); + + const currentBookText = (await bookChooser.textContent())?.trim() ?? ''; + if (book !== currentBookText) { + await user.click(bookChooser); + await user.click(page.getByRole('option', { name: book, exact: true })); + } + + const currentChapterText = (await chapterChooser.textContent())?.trim() ?? ''; + if (chapter.toString() !== currentChapterText) { + await user.click(chapterChooser); + await user.click(page.getByRole('option', { name: chapter.toString(), exact: true })); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html index 211542f123e..914e4e2cd87 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html @@ -149,6 +149,11 @@ + @if (experimentalFeatures.showExperimentalFeaturesInMenu) { + + } @if (featureFlags.showDeveloperTools.enabled) { } @empty { {{ t("no_books_have_drafts") }} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts index 28d9d4218fb..3d45a5b8cfc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.spec.ts @@ -76,6 +76,35 @@ describe('DraftPreviewBooks', () => { queryParams: { 'draft-active': true, 'draft-timestamp': undefined } }); })); + + it('shows the chapter range and navigates to the first drafted chapter for a partial book', fakeAsync(() => { + // GEN (book 1) has chapters 1-3 in the project, but only 2-3 were drafted, so this is a partial draft. + env = new TestEnvironment({ + build: { + additionalInfo: { translationScriptureRanges: [{ projectId: 'project01', scriptureRange: 'GEN2-3' }] } + } as BuildDto + }); + + expect(env.getBookButtonAtIndex(0).textContent).toContain('2-3'); + + env.getBookButtonAtIndex(0).click(); + tick(); + env.fixture.detectChanges(); + + const [url] = capture(mockedRouter.navigate).first(); + expect(url).toEqual(['/projects', 'project01', 'translate', 'GEN', '2']); + })); + + it('does not show a chapter range when the whole book was drafted', fakeAsync(() => { + // Genesis has 50 chapters; all 50 were drafted, so it is a whole-book draft and no range is shown. + env = new TestEnvironment({ + build: { + additionalInfo: { translationScriptureRanges: [{ projectId: 'project01', scriptureRange: 'GEN1-50' }] } + } as BuildDto + }); + + expect(env.getBookButtonAtIndex(0).textContent).not.toContain('('); + })); }); class TestEnvironment { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts index 16fa231cda1..827b63dd72f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts @@ -9,12 +9,17 @@ import { map, Observable, tap } from 'rxjs'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { filterNullish } from 'xforge-common/util/rxjs-util'; import { BuildDto } from '../../../machine-api/build-dto'; -import { booksFromScriptureRange } from '../../../shared/utils'; +import { expectedBookChapters } from '../../../shared/progress-service/progress.service'; +import { ChapterSet, VerboseScriptureRange } from '../../../shared/scripture-range'; export interface BookWithDraft { bookNumber: number; bookId: string; chaptersInBook: number[]; + /** Chapters drafted for this book, sorted. Empty for a whole-book draft or when the draft has no chapter detail. */ + draftedChapters: number[]; + /** Compact range of drafted chapters (e.g. "30-32"), set only when part of the book was drafted. */ + draftedChapterRange?: string; } @Component({ @@ -33,31 +38,16 @@ export class DraftPreviewBooksComponent { if (projectDoc?.data == null) { return []; } - let draftBooks: BookWithDraft[]; + const texts = projectDoc.data.texts; + let rangeStrings: string[]; if (this.build == null) { - // show every book with generated drafts - draftBooks = booksFromScriptureRange(projectDoc.data.translateConfig.draftConfig.draftedScriptureRange) - .map(bookNum => ({ - bookNumber: bookNum, - bookId: Canon.bookNumberToId(bookNum), - chaptersInBook: projectDoc.data?.texts.find(t => t.bookNum === bookNum)?.chapters.map(ch => ch.number) ?? [] - })) - .sort((a, b) => a.bookNumber - b.bookNumber); + // Every book with generated drafts, combined across previous drafts. + rangeStrings = [projectDoc.data.translateConfig.draftConfig.draftedScriptureRange ?? '']; } else { // TODO: Support books from multiple translation projects - draftBooks = this.build.additionalInfo?.translationScriptureRanges - .flatMap(range => booksFromScriptureRange(range.scriptureRange)) - .map(bookNum => { - const text: TextInfo | undefined = projectDoc.data?.texts.find(t => t.bookNum === bookNum); - return { - bookNumber: bookNum, - bookId: Canon.bookNumberToId(bookNum), - chaptersInBook: text?.chapters?.map(ch => ch.number) ?? [] - }; - }) - .sort((a, b) => a.bookNumber - b.bookNumber) as BookWithDraft[]; + rangeStrings = (this.build.additionalInfo?.translationScriptureRanges ?? []).map(range => range.scriptureRange); } - return draftBooks; + return this.booksWithDraft(VerboseScriptureRange.fromCombinedRanges(rangeStrings), texts); }) ); @@ -73,8 +63,39 @@ export class DraftPreviewBooksComponent { } navigate(book: BookWithDraft): void { - void this.router.navigate(this.linkForBookAndChapter(book.bookId, book.chaptersInBook[0] ?? 1), { + // Go to the first drafted chapter (for a partial draft this is not chapter 1); fall back to the book's first + // chapter, then to chapter 1. + const firstChapter = book.draftedChapters[0] ?? book.chaptersInBook[0] ?? 1; + void this.router.navigate(this.linkForBookAndChapter(book.bookId, firstChapter), { queryParams: { 'draft-active': true, 'draft-timestamp': this.build?.additionalInfo?.dateGenerated } }); } + + /** Builds the per-book draft info, skipping non-canonical/unknown books, sorted in canonical order. */ + private booksWithDraft(draftedRange: VerboseScriptureRange, texts: TextInfo[]): BookWithDraft[] { + const books: BookWithDraft[] = []; + for (const [bookId, draftedChapterSet] of draftedRange.books) { + const bookNumber = Canon.bookIdToNumber(bookId); + if (bookNumber <= 0) { + continue; + } + const chaptersInBook = texts.find(t => t.bookNum === bookNumber)?.chapters.map(ch => ch.number) ?? []; + const draftedChapters = [...draftedChapterSet.chapters].sort((a, b) => a - b); + // Show a range only when part of the book was drafted. "Whole book" is the canonical chapter count + // (expectedBookChapters), not the target's current chapters, which may only contain what has been drafted so + // far. A chapter-less range (a legacy or whole-book draft) has no drafted chapters and shows no range. + // Assumption: the canonical count comes from eng.vrs, so for a project using a different versification this can + // be off by a chapter at the end of a book. The exact reference would be the drafting source's chapter count, + // which is not available in this component. + const onlyPartDrafted = draftedChapters.length > 0 && draftedChapters.length < expectedBookChapters(bookId); + books.push({ + bookNumber: bookNumber, + bookId: bookId, + chaptersInBook: chaptersInBook, + draftedChapters: draftedChapters, + draftedChapterRange: onlyPartDrafted ? new ChapterSet(draftedChapters).toStringForDisplay() : undefined + }); + } + return books.sort((a, b) => a.bookNumber - b.bookNumber); + } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html index cd1867a6e14..cec35d856e4 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html @@ -169,7 +169,6 @@

{{ t("title") }}

[selectedBooks]="selectedSubmittedBooks" (bookSelect)="onSubmittedBooksSelect($event)" [basicMode]="true" - [projectName]="currentProjectDisplayName" > @if (showPlannedBooksRequiredError) { {{ t("planned_books_required") }} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-source.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-source.ts index 7c5fcbdeb6d..899e8e95812 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-source.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-source.ts @@ -1,3 +1,4 @@ +import { uniqBy } from 'lodash-es'; import { TranslateSource } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; interface DraftTextInfo { @@ -11,6 +12,24 @@ export interface DraftSource extends TranslateSource { copyrightNotice?: string; } +/** Copyright banner (and optional notice) for a draft source project. */ +export interface CopyrightMessage { + banner: string; + notice?: string; +} + +/** The distinct copyright messages across the given sources, deduplicated by banner text. */ +export function getCopyrightMessages( + sources: { copyrightBanner?: string; copyrightNotice?: string }[] +): CopyrightMessage[] { + return uniqBy( + sources + .filter(source => source.copyrightBanner != null) + .map(source => ({ banner: source.copyrightBanner!, notice: source.copyrightNotice })), + message => message.banner + ); +} + export interface DraftSourcesAsArrays { trainingSources: DraftSource[]; trainingTargets: DraftSource[]; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts index 9912b5ba04d..408c79114c6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.spec.ts @@ -34,21 +34,24 @@ describe('DraftSourcesService', () => { }); describe('getDraftProjectSources', () => { - it('should pass undefined properties if no projects loaded', done => { - when(mockActivatedProjectService.projectId).thenReturn(undefined); - when(mockActivatedProjectService.changes$).thenReturn( - new BehaviorSubject({ data: undefined } as SFProjectProfileDoc) - ); + it('skips emissions while the doc data is not loaded, then emits the real sources once it is', done => { + // Doc present but its data hasn't loaded yet. The service must not emit the all-empty fallback for this; + // otherwise a firstValueFrom consumer would latch it instead of the real sources. + const changes$ = new BehaviorSubject({ data: undefined } as SFProjectProfileDoc); + when(mockActivatedProjectService.changes$).thenReturn(changes$); + + const targetProject = createTestProjectProfile({ + translateConfig: { draftConfig: { draftingSources: [], trainingSources: [] } } + }); - // SUT service.getDraftProjectSources().subscribe(result => { - expect(result).toEqual({ - trainingSources: [], - trainingTargets: [], - draftingSources: [] - } as DraftSourcesAsArrays); + // The first (and only) emission is the loaded one, never the empty fallback (trainingTargets would be []). + expect(result.trainingTargets).toEqual([{ ...targetProject, projectRef: 'project01' }]); done(); }); + + // Provide the data after the initial (filtered) emission has had a chance to fire. + setTimeout(() => changes$.next({ id: 'project01', data: targetProject } as SFProjectProfileDoc), 0); }); it('should pass the values from the target project if no access', done => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts index b5199bd4ad2..1f623843f23 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-sources.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; import { TranslateSource } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; import { asyncScheduler, combineLatest, defer, from, Observable } from 'rxjs'; -import { switchMap, throttleTime } from 'rxjs/operators'; +import { filter, switchMap, throttleTime } from 'rxjs/operators'; import { ActivatedProjectService } from 'xforge-common/activated-project.service'; import { UserDoc } from 'xforge-common/models/user-doc'; import { UserService } from 'xforge-common/user.service'; @@ -41,6 +41,9 @@ export class DraftSourcesService { */ getDraftProjectSources(): Observable { return combineLatest([this.activatedProject.changes$, this.currentUser$]).pipe( + // Ignore emissions where the doc's data hasn't loaded yet; otherwise getDraftSourcesAsArrays returns all-empty + // arrays and a firstValueFrom consumer latches that instead of the real sources. + filter(([targetDoc]) => targetDoc?.data != null), throttleTime(this.projectChangeThrottlingMs, asyncScheduler, { leading: true, trailing: true }), switchMap(([targetDoc, _currentUser]) => this.getDraftSourcesAsArrays(targetDoc)) ); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.html new file mode 100644 index 00000000000..3c597447db4 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.html @@ -0,0 +1,47 @@ +
+ @if (loading) { + + } @else { +

{{ t("heading") }}

+

{{ t("body") }}

+ +
+ @for (row of rows; track row.projectId) { +
+ {{ row.name }} + + @if (row.canSync) { + @if (row.syncState === "pending") { + + } @else if (row.syncState === "syncing") { + + } @else if (row.syncState === "synced") { + check_circle{{ t("synced") }} + } @else if (row.syncState === "failed") { + warning{{ t("sync_failed") }} + + } + } @else { + warning{{ t("cant_sync") }} + } + +
+ } +
+ +
+ + @if (syncableRows.length > 1) { + + } + +
+ @if (userSyncInProgress) { +

{{ t("wait_for_sync") }}

+ } + } +
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.scss new file mode 100644 index 00000000000..519c2547693 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.scss @@ -0,0 +1,45 @@ +@use 'src/variables'; + +.pending-project-rows { + display: flex; + flex-direction: column; + gap: 12px; + margin: 16px 0; +} + +.pending-project-row { + display: flex; + align-items: center; + gap: 16px; +} + +.project-name { + min-width: 160px; + font-weight: 500; +} + +.row-status { + display: flex; + align-items: center; + gap: 8px; +} + +.sync-done, +.sync-failed, +.cant-sync { + display: flex; + align-items: center; + gap: 4px; +} + +.pending-actions { + display: flex; + gap: 12px; + margin-top: 24px; +} + +.wait-for-sync { + color: variables.$lighterTextColor; + font-size: 0.85em; + margin: 8px 0 0; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.spec.ts new file mode 100644 index 00000000000..85e6ffd7526 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.spec.ts @@ -0,0 +1,477 @@ +import { DestroyRef } from '@angular/core'; +import { fakeAsync, tick } from '@angular/core/testing'; +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 { Subject } from 'rxjs'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { UserService } from 'xforge-common/user.service'; +import { SFProjectDoc } from '../../../../core/models/sf-project-doc'; +import { PermissionsService } from '../../../../core/permissions.service'; +import { SFProjectService } from '../../../../core/sf-project.service'; +import { DraftPendingUpdatesComponent } from './draft-pending-updates.component'; + +const USER_ID = 'test-user-id'; + +describe('DraftPendingUpdatesComponent', () => { + describe('ngOnInit', () => { + it('creates a syncable row when user has Texts.Edit on the project', async () => { + const env = new TestEnvironment([makeProject('proj1', 'Project One', SFProjectRole.ParatextAdministrator)]); + + await env.component.ngOnInit(); + + expect(env.component.rows.length).toBe(1); + expect(env.component.rows[0].projectId).toBe('proj1'); + expect(env.component.rows[0].name).toBe('Project One'); + expect(env.component.rows[0].canSync).toBeTrue(); + expect(env.component.rows[0].syncState).toBe('pending'); + }); + + it('creates a non-syncable row when user lacks Texts.Edit', async () => { + const env = new TestEnvironment([makeProject('proj1', 'Project One', SFProjectRole.CommunityChecker)]); + + await env.component.ngOnInit(); + + expect(env.component.rows[0].canSync).toBeFalse(); + }); + + it('creates a syncable row for a DBL resource when the user has any Paratext role', async () => { + // A Paratext observer cannot edit, but may still sync a DBL resource. + const env = new TestEnvironment([makeResource('res1', 'Resource One', SFProjectRole.ParatextObserver)]); + + await env.component.ngOnInit(); + + expect(env.component.rows[0].canSync).toBeTrue(); + }); + + it('creates a non-syncable row for a DBL resource when the user has no Paratext role', async () => { + const env = new TestEnvironment([makeResource('res1', 'Resource One', SFProjectRole.CommunityChecker)]); + + await env.component.ngOnInit(); + + expect(env.component.rows[0].canSync).toBeFalse(); + }); + + it('sets syncState to syncing when project is already syncing', async () => { + const env = new TestEnvironment([makeProject('proj1', 'Project One', SFProjectRole.ParatextAdministrator, 1)]); + + await env.component.ngOnInit(); + + expect(env.component.rows[0].syncState).toBe('syncing'); + }); + + it('sets loading to false after init completes', async () => { + const env = new TestEnvironment([]); + expect(env.component.loading).toBeTrue(); + + await env.component.ngOnInit(); + + expect(env.component.loading).toBeFalse(); + }); + + it('builds rows for multiple pending projects', async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'Project One', SFProjectRole.ParatextAdministrator), + makeProject('proj2', 'Project Two', SFProjectRole.CommunityChecker) + ]); + + await env.component.ngOnInit(); + + expect(env.component.rows.length).toBe(2); + expect(env.component.rows[0].canSync).toBeTrue(); + expect(env.component.rows[1].canSync).toBeFalse(); + }); + }); + + describe('syncableRows', () => { + it('returns only rows where canSync is true', async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator), + makeProject('proj2', 'P2', SFProjectRole.CommunityChecker) + ]); + await env.component.ngOnInit(); + + expect(env.component.syncableRows.length).toBe(1); + expect(env.component.syncableRows[0].projectId).toBe('proj1'); + }); + }); + + describe('syncProject', () => { + it('calls onlineSync and sets syncState to syncing', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + + env.component.syncProject(env.component.rows[0]); + + expect(env.component.rows[0].syncState).toBe('syncing'); + verify(env.mockedProjectService.onlineSync('proj1')).once(); + }); + + it('does nothing when row is already syncing', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + env.component.rows[0].syncState = 'syncing'; + when(env.mockedProjectService.onlineSync(anything())).thenResolve(); + + env.component.syncProject(env.component.rows[0]); + + verify(env.mockedProjectService.onlineSync(anything())).never(); + expect().nothing(); + }); + + it('does nothing when canSync is false', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.CommunityChecker)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync(anything())).thenResolve(); + + env.component.syncProject(env.component.rows[0]); + + verify(env.mockedProjectService.onlineSync(anything())).never(); + expect().nothing(); + }); + + it('sets syncState to failed when onlineSync rejects', fakeAsync(async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenReject(new Error('network error')); + + env.component.syncProject(env.component.rows[0]); + tick(); + + expect(env.component.rows[0].syncState).toBe('failed'); + })); + }); + + describe('retrySyncProject', () => { + it('resets state to pending and calls syncProject', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + env.component.rows[0].syncState = 'failed'; + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + + env.component.retrySyncProject(env.component.rows[0]); + + expect(env.component.rows[0].syncState).toBe('syncing'); + verify(env.mockedProjectService.onlineSync('proj1')).once(); + }); + }); + + describe('syncAll', () => { + it('calls syncProject for all syncable pending rows', async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator), + makeProject('proj2', 'P2', SFProjectRole.ParatextTranslator), + makeProject('proj3', 'P3', SFProjectRole.CommunityChecker) + ]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync(anything())).thenResolve(); + + env.component.syncAll(); + + verify(env.mockedProjectService.onlineSync('proj1')).once(); + verify(env.mockedProjectService.onlineSync('proj2')).once(); + verify(env.mockedProjectService.onlineSync('proj3')).never(); + expect().nothing(); + }); + + it('skips rows already syncing', async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator), + makeProject('proj2', 'P2', SFProjectRole.ParatextAdministrator) + ]); + await env.component.ngOnInit(); + env.component.rows[0].syncState = 'syncing'; + when(env.mockedProjectService.onlineSync(anything())).thenResolve(); + + env.component.syncAll(); + + verify(env.mockedProjectService.onlineSync('proj1')).never(); + verify(env.mockedProjectService.onlineSync('proj2')).once(); + expect().nothing(); + }); + }); + + describe('sync completion', () => { + it('resolves to synced on the queuedCount high->low edge when lastSyncSuccessful is true', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + env.component.syncProject(env.component.rows[0]); + + env.startSync('proj1'); + expect(env.component.rows[0].syncState).toBe('syncing'); + + env.completeSync('proj1', true); + expect(env.component.rows[0].syncState).toBe('synced'); + }); + + it('resolves to failed on completion when lastSyncSuccessful is false', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + env.component.syncProject(env.component.rows[0]); + + env.startSync('proj1'); + env.completeSync('proj1', false); + + expect(env.component.rows[0].syncState).toBe('failed'); + }); + + it('does not resolve prematurely on a queuedCount=0 change before the sync starts', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + env.component.syncProject(env.component.rows[0]); + + // A remote change arrives before the sync has been enqueued (queuedCount still 0). + env.emitRemoteChange('proj1'); + + expect(env.component.rows[0].syncState).toBe('syncing'); + }); + + it('resolves a row that is already syncing when the wizard opens', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1)]); + await env.component.ngOnInit(); + expect(env.component.rows[0].syncState).toBe('syncing'); + + env.completeSync('proj1', true); + + expect(env.component.rows[0].syncState).toBe('synced'); + }); + }); + + describe('Continue anyway gating', () => { + it('blocks continuing while a sync the user started is still running', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + expect(env.component.userSyncInProgress).toBeFalse(); + + env.component.syncProject(env.component.rows[0]); + + expect(env.component.userSyncInProgress).toBeTrue(); + }); + + it('still allows continuing when a project was already syncing on entry (not user-initiated)', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1)]); + await env.component.ngOnInit(); + + expect(env.component.rows[0].syncState).toBe('syncing'); + // A pre-existing (possibly stuck) sync must not trap the user, so continuing stays allowed. + expect(env.component.userSyncInProgress).toBeFalse(); + }); + + it('allows continuing again once the user-initiated sync finishes', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + when(env.mockedProjectService.onlineSync('proj1')).thenResolve(); + env.component.syncProject(env.component.rows[0]); + env.startSync('proj1'); + expect(env.component.userSyncInProgress).toBeTrue(); + + env.completeSync('proj1', true); + + expect(env.component.userSyncInProgress).toBeFalse(); + }); + }); + + describe('continueAnyway', () => { + it('emits continue', async () => { + const env = new TestEnvironment([]); + await env.component.ngOnInit(); + let emitted = false; + env.component.continue.subscribe(() => (emitted = true)); + + env.component.continueAnyway(); + + expect(emitted).toBeTrue(); + }); + + it('emits the IDs of the projects that synced', async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1), + makeProject('proj2', 'P2', SFProjectRole.ParatextAdministrator) + ]); + await env.component.ngOnInit(); + let emitted: string[] | undefined; + env.component.continue.subscribe(ids => (emitted = ids)); + + env.completeSync('proj1', true); // proj1 syncs; proj2 stays pending + + env.component.continueAnyway(); + + expect(emitted).toEqual(['proj1']); + }); + + it('emits an empty array when nothing was synced', async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator)]); + await env.component.ngOnInit(); + let emitted: string[] | undefined; + env.component.continue.subscribe(ids => (emitted = ids)); + + env.component.continueAnyway(); + + expect(emitted).toEqual([]); + }); + }); + + describe('auto-advance', () => { + it('auto-advances after delay when all syncable rows are synced and no cant-sync rows', fakeAsync(async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1)]); + await env.component.ngOnInit(); + let emitted: string[] | undefined; + env.component.continue.subscribe(ids => (emitted = ids)); + + env.completeSync('proj1', true); + expect(emitted).toBeUndefined(); + + tick(1500); + expect(emitted).toEqual(['proj1']); + })); + + it('does not auto-advance when cant-sync rows exist', fakeAsync(async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1), + makeProject('proj2', 'P2', SFProjectRole.CommunityChecker) + ]); + await env.component.ngOnInit(); + let emitted = false; + env.component.continue.subscribe(() => (emitted = true)); + + env.completeSync('proj1', true); + tick(1500); + + expect(emitted).toBeFalse(); + })); + + it('does not auto-advance when some syncable rows are still pending', fakeAsync(async () => { + const env = new TestEnvironment([ + makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1), + makeProject('proj2', 'P2', SFProjectRole.ParatextAdministrator) + ]); + await env.component.ngOnInit(); + let emitted = false; + env.component.continue.subscribe(() => (emitted = true)); + + // Only proj1 finishes; proj2 is still pending + env.completeSync('proj1', true); + tick(1500); + + expect(emitted).toBeFalse(); + })); + + it('does not auto-advance when a sync failed', fakeAsync(async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1)]); + await env.component.ngOnInit(); + let emitted = false; + env.component.continue.subscribe(() => (emitted = true)); + + env.completeSync('proj1', false); + tick(1500); + + expect(emitted).toBeFalse(); + })); + + it('cancels the pending auto-advance when the component is destroyed', fakeAsync(async () => { + const env = new TestEnvironment([makeProject('proj1', 'P1', SFProjectRole.ParatextAdministrator, 1)]); + await env.component.ngOnInit(); + let emitted = false; + env.component.continue.subscribe(() => (emitted = true)); + + env.completeSync('proj1', true); // schedules the auto-advance + env.destroyRef.destroy(); // component torn down before it fires + tick(1500); + + expect(emitted).toBeFalse(); + })); + }); +}); + +interface ProjectSpec { + projectId: string; + name: string; + role: SFProjectRole; + queuedCount: number; + paratextId?: string; +} + +function makeProject(projectId: string, name: string, role: SFProjectRole, queuedCount = 0): ProjectSpec { + return { projectId, name, role, queuedCount }; +} + +// A DBL resource is identified by a 16-character paratextId. +function makeResource(projectId: string, name: string, role: SFProjectRole, queuedCount = 0): ProjectSpec { + return { projectId, name, role, queuedCount, paratextId: 'resource16char01' }; +} + +/** A DestroyRef whose registered teardown callbacks can be fired on demand via destroy(). */ +class FakeDestroyRef implements DestroyRef { + destroyed = false; + private callbacks: (() => void)[] = []; + + onDestroy(callback: () => void): () => void { + this.callbacks.push(callback); + return () => (this.callbacks = this.callbacks.filter(cb => cb !== callback)); + } + + destroy(): void { + this.destroyed = true; + this.callbacks.forEach(cb => cb()); + this.callbacks = []; + } +} + +class TestEnvironment { + component: DraftPendingUpdatesComponent; + readonly mockedProjectService = mock(SFProjectService); + readonly destroyRef = new FakeDestroyRef(); + private readonly mockedUserService = mock(UserService); + private readonly projectDocs = new Map }>(); + + constructor(projects: ProjectSpec[]) { + when(this.mockedUserService.currentUserId).thenReturn(USER_ID); + + for (const spec of projects) { + const projectData = createTestProjectProfile({ + shortName: spec.name, + userRoles: { [USER_ID]: spec.role }, + sync: { queuedCount: spec.queuedCount }, + ...(spec.paratextId != null ? { paratextId: spec.paratextId } : {}) + }); + const doc = { data: projectData, remoteChanges$: new Subject() } as unknown as SFProjectDoc; + this.projectDocs.set(spec.projectId, doc as any); + when(this.mockedProjectService.get(spec.projectId)).thenResolve(doc); + } + + // Use a real PermissionsService so the role/resource permission logic is exercised, not stubbed. + const permissionsService = new PermissionsService( + instance(this.mockedUserService), + instance(this.mockedProjectService) + ); + this.component = new DraftPendingUpdatesComponent( + instance(this.mockedProjectService), + permissionsService, + this.destroyRef + ); + this.component.pendingProjects = projects.map(p => ({ projectId: p.projectId, name: p.name })); + } + + /** Simulate the project's sync becoming active (queuedCount > 0) and notify subscribers. */ + startSync(projectId: string): void { + const doc = this.projectDocs.get(projectId)!; + doc.data.sync = { ...doc.data.sync, queuedCount: 1 }; + doc.remoteChanges$.next(); + } + + /** Simulate the project's sync completing (queuedCount back to 0) and notify subscribers. */ + completeSync(projectId: string, successful: boolean): void { + const doc = this.projectDocs.get(projectId)!; + doc.data.sync = { ...doc.data.sync, queuedCount: 0, lastSyncSuccessful: successful }; + doc.remoteChanges$.next(); + } + + /** Emit a remote change without altering sync state (e.g. an unrelated doc edit). */ + emitRemoteChange(projectId: string): void { + this.projectDocs.get(projectId)!.remoteChanges$.next(); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.stories.ts new file mode 100644 index 00000000000..377aa0b2fc5 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.stories.ts @@ -0,0 +1,204 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; +import { Subject } from 'rxjs'; +import userEvent from '@testing-library/user-event'; +import { expect, waitFor, within } from 'storybook/test'; +import { anything, instance, mock, reset, when } from 'ts-mockito'; +import { createTestFeatureFlag, FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; +import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { SFProjectDoc } from '../../../../core/models/sf-project-doc'; +import { PermissionsService } from '../../../../core/permissions.service'; +import { ProjectNotificationService } from '../../../../core/project-notification.service'; +import { SFProjectService } from '../../../../core/sf-project.service'; +import { DraftPendingUpdatesComponent } from './draft-pending-updates.component'; + +// Dependencies of the component (and of the SyncProgressComponent it renders while a project syncs). +const mockedSFProjectService = mock(SFProjectService); +const mockedPermissionsService = mock(PermissionsService); +const mockedProjectNotificationService = mock(ProjectNotificationService); +const mockedFeatureFlagService = mock(FeatureFlagService); +const mockedErrorReportingService = mock(ErrorReportingService); +const mockedOnlineStatusService = mock(OnlineStatusService); + +/** Describes one row of the interstitial. `queuedCount > 0` makes the project syncing on entry. */ +interface ProjectSpec { + projectId: string; + name: string; + canSync: boolean; + queuedCount?: number; + lastSyncSuccessful?: boolean; + /** The project doc never loads, so the component stays on its loading spinner. */ + neverLoad?: boolean; + /** When the user starts a sync for this project, immediately drive it to this outcome (for the succeeded/failed states). */ + completeOnSyncWith?: 'success' | 'failure'; +} + +// Crafted project docs keyed by id, so the play functions can drive a sync to completion after the user starts it. +const docs = new Map; data: any }>(); + +function setUpMocks(specs: ProjectSpec[]): void { + for (const m of [ + mockedSFProjectService, + mockedPermissionsService, + mockedProjectNotificationService, + mockedFeatureFlagService, + mockedErrorReportingService, + mockedOnlineStatusService + ]) { + reset(m); + } + docs.clear(); + + const specById = new Map(specs.map(s => [s.projectId, s])); + for (const spec of specs) { + const remoteChanges$ = new Subject(); + const data = createTestProjectProfile({ + shortName: spec.name, + sync: { queuedCount: spec.queuedCount ?? 0, lastSyncSuccessful: spec.lastSyncSuccessful ?? true } + }); + docs.set(spec.projectId, { + doc: { id: spec.projectId, data, remoteChanges$ } as unknown as SFProjectDoc, + remoteChanges$, + data + }); + } + + when(mockedSFProjectService.get(anything())).thenCall((id: string) => + specById.get(id)?.neverLoad ? new Promise(() => {}) : Promise.resolve(docs.get(id)!.doc) + ); + // Drive the sync to completion within the (zoned) sync call so change detection updates the row to its final state. + when(mockedSFProjectService.onlineSync(anything())).thenCall((id: string) => { + const outcome = specById.get(id)?.completeOnSyncWith; + if (outcome != null) completeSync(id, outcome === 'success'); + return Promise.resolve(); + }); + when(mockedPermissionsService.canSync(anything())).thenCall( + (doc: SFProjectDoc) => specById.get(doc?.id ?? '')?.canSync ?? false + ); + + // SyncProgressComponent dependencies (it's rendered for a project that is syncing in place). + when(mockedProjectNotificationService.start()).thenResolve(); + when(mockedProjectNotificationService.stop()).thenResolve(); + when(mockedProjectNotificationService.subscribeToProject(anything())).thenResolve(); + when(mockedFeatureFlagService.stillness).thenReturn(createTestFeatureFlag(false)); + when(mockedOnlineStatusService.isOnline).thenReturn(true); + when(mockedOnlineStatusService.isBrowserOnline).thenReturn(true); +} + +/** Drives a row's project to a completed sync (queuedCount 1 → 0) after the user has started it. */ +function completeSync(projectId: string, successful: boolean): void { + const entry = docs.get(projectId)!; + entry.data.sync.queuedCount = 1; + entry.remoteChanges$.next([]); // observed as "now syncing" + entry.data.sync.queuedCount = 0; + entry.data.sync.lastSyncSuccessful = successful; + entry.remoteChanges$.next([]); // observed as the syncing → done edge +} + +interface StoryArgs { + projects: ProjectSpec[]; +} + +const meta: Meta = { + title: 'Translate/NewDraft/PendingUpdates', + component: DraftPendingUpdatesComponent, + parameters: { controls: { disable: true } }, + render: args => { + setUpMocks(args.projects); + return { + props: { pendingProjects: args.projects.map(p => ({ projectId: p.projectId, name: p.name })) }, + moduleMetadata: { + imports: [DraftPendingUpdatesComponent], + providers: [ + { provide: SFProjectService, useValue: instance(mockedSFProjectService) }, + { provide: PermissionsService, useValue: instance(mockedPermissionsService) }, + { provide: ProjectNotificationService, useValue: instance(mockedProjectNotificationService) }, + { provide: FeatureFlagService, useValue: instance(mockedFeatureFlagService) }, + { provide: ErrorReportingService, useValue: instance(mockedErrorReportingService) }, + { provide: OnlineStatusService, useValue: instance(mockedOnlineStatusService) } + ] + } + }; + } +}; + +export default meta; + +type Story = StoryObj; + +/** A single syncable project: it uses its own row's Sync button (no "Sync all", which would imply more than one). */ +export const SingleSyncableProject: Story = { + args: { projects: [{ projectId: 'p1', name: 'Greek NT', canSync: true }] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Greek NT'); + expect(canvas.queryByRole('button', { name: /sync all/i })).toBeNull(); + } +}; + +/** Several syncable projects: each has its own Sync button, plus a "Sync all" primary action. */ +export const MultipleSyncableProjects: Story = { + args: { + projects: [ + { projectId: 'p1', name: 'Greek NT', canSync: true }, + { projectId: 'p2', name: 'Hebrew OT', canSync: true }, + { projectId: 'p3', name: 'Back Translation', canSync: true } + ] + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole('button', { name: /sync all/i }); + } +}; + +/** The user lacks Texts.Edit, so it's informational only; they must ask an admin or continue anyway. */ +export const CannotSync: Story = { + args: { projects: [{ projectId: 'p1', name: 'Greek NT', canSync: false }] } +}; + +/** A mix: one project the user can sync and one they can't. */ +export const MixedPermissions: Story = { + args: { + projects: [ + { projectId: 'p1', name: 'Greek NT', canSync: true }, + { projectId: 'p2', name: 'Hebrew OT', canSync: false } + ] + } +}; + +/** A project that was already syncing when the wizard opened shows live progress and no Sync button. */ +export const AlreadySyncing: Story = { + args: { projects: [{ projectId: 'p1', name: 'Greek NT', canSync: true, queuedCount: 1 }] } +}; + +/** Project docs haven't loaded yet. */ +export const Loading: Story = { + args: { projects: [{ projectId: 'p1', name: 'Greek NT', canSync: true, neverLoad: true }] }, + play: async ({ canvasElement }) => { + await waitFor(() => expect(canvasElement.querySelector('.loading-indicator')).not.toBeNull()); + } +}; + +/** Start a sync, then let it finish successfully; the row shows "Up to date". */ +export const SyncSucceeded: Story = { + args: { projects: [{ projectId: 'p1', name: 'Greek NT', canSync: true, completeOnSyncWith: 'success' }] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Greek NT'); + await userEvent.click(await canvas.findByRole('button', { name: /sync/i })); + await waitFor(() => expect(canvas.getByText('Up to date')).not.toBeNull()); + } +}; + +/** Start a sync, then let it fail; the row shows "Sync failed" with a Retry button. */ +export const SyncFailed: Story = { + args: { projects: [{ projectId: 'p1', name: 'Greek NT', canSync: true, completeOnSyncWith: 'failure' }] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Greek NT'); + await userEvent.click(await canvas.findByRole('button', { name: /sync/i })); + await waitFor(() => expect(canvas.getByText('Sync failed')).not.toBeNull()); + await canvas.findByRole('button', { name: /retry/i }); + } +}; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.ts new file mode 100644 index 00000000000..c1369c5a45e --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/draft-pending-updates/draft-pending-updates.component.ts @@ -0,0 +1,162 @@ +import { Component, DestroyRef, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { TranslocoModule } from '@ngneat/transloco'; +import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; +import { SFProjectDoc } from '../../../../core/models/sf-project-doc'; +import { PermissionsService } from '../../../../core/permissions.service'; +import { SFProjectService } from '../../../../core/sf-project.service'; +import { isSFProjectSyncing } from '../../../../sync/sync.component'; +import { SyncProgressComponent } from '../../../../sync/sync-progress/sync-progress.component'; + +/** How long the "All synced" state lingers before auto-advancing, so the transition isn't jarring. */ +const AUTO_ADVANCE_DELAY_MS = 1500; + +interface PendingProjectRow { + projectId: string; + name: string; + canSync: boolean; + projectDoc?: SFProjectDoc; + syncState: 'pending' | 'syncing' | 'synced' | 'failed'; + /** Latch tracking whether this project has been observed actively syncing, so completion is the high→low edge. */ + wasSyncing: boolean; + /** Whether a remoteChanges$ subscription is already watching this row's sync state. */ + monitoring: boolean; + /** + * Whether the user kicked off this row's sync from the pre-step (vs. it already syncing on entry). A user-initiated + * sync suppresses "Continue anyway" (they chose to wait); a pre-existing one does not (it may be stuck, and we must + * not trap the user). + */ + userInitiated: boolean; +} + +@Component({ + selector: 'app-draft-pending-updates', + templateUrl: './draft-pending-updates.component.html', + styleUrls: ['./draft-pending-updates.component.scss'], + imports: [MatButtonModule, MatIconModule, MatProgressSpinner, SyncProgressComponent, TranslocoModule] +}) +export class DraftPendingUpdatesComponent implements OnInit { + @Input() pendingProjects: { projectId: string; name: string }[] = []; + /** Emits when the user leaves the pre-step, carrying the IDs of the projects that were synced in place (if any). */ + @Output() continue = new EventEmitter(); + + rows: PendingProjectRow[] = []; + loading = true; + + private autoAdvanceTimeout?: ReturnType; + + constructor( + private readonly projectService: SFProjectService, + private readonly permissionsService: PermissionsService, + private readonly destroyRef: DestroyRef + ) { + this.destroyRef.onDestroy(() => { + if (this.autoAdvanceTimeout != null) clearTimeout(this.autoAdvanceTimeout); + }); + } + + async ngOnInit(): Promise { + // Load all the project docs concurrently, then build rows in the original order. + const projectDocs = await Promise.all(this.pendingProjects.map(p => this.projectService.get(p.projectId))); + for (const [i, { projectId, name }] of this.pendingProjects.entries()) { + const projectDoc = projectDocs[i]; + const canSync = this.permissionsService.canSync(projectDoc); + const syncing = projectDoc.data != null && isSFProjectSyncing(projectDoc.data); + const row: PendingProjectRow = { + projectId, + name, + canSync, + projectDoc, + syncState: syncing ? 'syncing' : 'pending', + wasSyncing: syncing, + monitoring: false, + userInitiated: false + }; + this.rows.push(row); + // A project already syncing when the wizard opens still needs its completion observed. + if (syncing) this.monitorSync(row); + } + this.loading = false; + } + + get syncableRows(): PendingProjectRow[] { + return this.rows.filter(r => r.canSync); + } + + /** + * Whether a sync the user started from this pre-step is still running. While true, "Continue anyway" is suppressed + * so the user can't bail out of a sync they chose to start. It clears automatically once that sync reaches a + * terminal state. A project that was already syncing on entry does not count, so a stuck project can't trap the user. + */ + get userSyncInProgress(): boolean { + return this.rows.some(r => r.userInitiated && r.syncState === 'syncing'); + } + + syncProject(row: PendingProjectRow): void { + if (!row.canSync || row.syncState === 'syncing') return; + row.userInitiated = true; + row.syncState = 'syncing'; + // Subscribe before the network round-trip so the queuedCount transition can't be missed. + this.monitorSync(row); + this.projectService.onlineSync(row.projectId).catch(() => { + // Failure to even enqueue the sync (e.g. RPC/network error). + row.syncState = 'failed'; + }); + } + + retrySyncProject(row: PendingProjectRow): void { + row.syncState = 'pending'; + this.syncProject(row); + } + + syncAll(): void { + for (const row of this.syncableRows) { + if (row.syncState === 'pending') this.syncProject(row); + } + } + + continueAnyway(): void { + this.continue.emit(this.syncedProjectIds()); + } + + /** IDs of the projects that completed a sync during this pre-step (so the wizard can re-derive from fresh data). */ + private syncedProjectIds(): string[] { + return this.rows.filter(r => r.syncState === 'synced').map(r => r.projectId); + } + + /** Watches a row's project doc for the sync to complete (queuedCount returning to 0). */ + private monitorSync(row: PendingProjectRow): void { + if (row.monitoring || row.projectDoc == null) return; + row.monitoring = true; + row.projectDoc.remoteChanges$ + .pipe(quietTakeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.checkSyncStatus(row)); + // Resolve immediately in case the sync already finished during the get()/subscribe gap. + this.checkSyncStatus(row); + } + + private checkSyncStatus(row: PendingProjectRow): void { + const data = row.projectDoc?.data; + if (data == null) return; + if (isSFProjectSyncing(data)) { + row.wasSyncing = true; + row.syncState = 'syncing'; + } else if (row.wasSyncing) { + // High→low edge of queuedCount: the sync that we observed running has now completed. + row.wasSyncing = false; + row.syncState = data.sync.lastSyncSuccessful === true ? 'synced' : 'failed'; + this.checkAutoAdvance(); + } + } + + private checkAutoAdvance(): void { + if (this.autoAdvanceTimeout != null || this.syncableRows.length === 0) return; + const hasCannotSyncRow = this.rows.some(r => !r.canSync); + const allSyncableSynced = this.syncableRows.every(r => r.syncState === 'synced'); + if (!hasCannotSyncRow && allSyncableSynced) { + this.autoAdvanceTimeout = setTimeout(() => this.continue.emit(this.syncedProjectIds()), AUTO_ADVANCE_DELAY_MS); + } + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.spec.ts new file mode 100644 index 00000000000..c0e9f7457ca --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.spec.ts @@ -0,0 +1,1147 @@ +import { DestroyRef } from '@angular/core'; +import { Canon } from '@sillsdev/scripture'; +import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; +import { ProjectScriptureRange } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; +import { BehaviorSubject, filter, firstValueFrom, of } from 'rxjs'; +import { anything, deepEqual, instance, mock, resetCalls, verify, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { chapterCounts } from '../../../shared/progress-service/progress.service'; +import { VerboseScriptureRange } from '../../../shared/scripture-range'; +import { DraftSource, DraftSourcesAsArrays } from '../draft-source'; +import { DraftSourcesService } from '../draft-sources.service'; +import { DraftProgressService, ExcludedDraftingBook, NewDraftLogicHandler } from './new-draft-logic-handler'; + +const mockDestroyRef = { onDestroy: () => () => {}, destroyed: false } as unknown as DestroyRef; + +function allBooksExcept(excludedBooks: string[]): string { + return new VerboseScriptureRange( + Canon.allBookIds + .filter(book => Canon.isBookOTNT(Canon.bookIdToNumber(book)) && !excludedBooks.includes(book)) + .map(book => `${book}1-${chapterCounts[book]}`) + .join(';') + ).toString(); +} + +const FULL_CANON_SCRIPTURE_RANGE = allBooksExcept([]); + +const ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET_ORIGINAL_VALUE = NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET; + +describe('NewDraftLogicHandler', () => { + const teamStartingToTranslateGenesis = { + lastSelectedTranslationScriptureRanges: [{ projectId: 'draft-source-1-id', scriptureRange: 'GEN' }], + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT;MRK;LUK;JHN' } + ], + draftingSourceBooksChapters: FULL_CANON_SCRIPTURE_RANGE, + targetProjectBooksChapters: 'GEN1-5;MAT1-28;MRK1-16;LUK1-24;JHN1-21', + trainingSourcesBooksChapters: { + 'training-source-1-id': FULL_CANON_SCRIPTURE_RANGE + } + } as const satisfies TestState; + + const teamWithTwoTrainingSources = { + lastSelectedTranslationScriptureRanges: undefined, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT;MRK' }, + { projectId: 'training-source-2-id', scriptureRange: 'LUK;JHN' } + ], + draftingSourceBooksChapters: FULL_CANON_SCRIPTURE_RANGE, + targetProjectBooksChapters: 'GEN1-5;MAT1-28;MRK1-16;LUK1-24;JHN1-21', + trainingSourcesBooksChapters: { + 'training-source-1-id': FULL_CANON_SCRIPTURE_RANGE, + 'training-source-2-id': FULL_CANON_SCRIPTURE_RANGE + } + } as const satisfies TestState; + + // The gate is shared static state; reset it after each test so a gate test doesn't leak into other specs. + afterEach(() => { + NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET = ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET_ORIGINAL_VALUE; + }); + + describe('initialization', () => { + it('aborts when a source project is inaccessible', async () => { + const env = new TestEnvironment({ ...teamStartingToTranslateGenesis, noAccessSources: true }); + await env.waitForAbort(); + + expect(env.logicHandler.status$.getValue()).toBe('abort'); + expect(env.logicHandler.abortMode).toBe('no_access'); + }); + + it('aborts with config_changed when sources change after initialization', async () => { + const sources$ = new BehaviorSubject({ + trainingSources: [], + trainingTargets: [], + draftingSources: [{ projectRef: 'draft-source-1-id' } as DraftSource] + }); + const env = new TestEnvironment(teamStartingToTranslateGenesis, sources$); + await env.waitForInit(); + + sources$.next({ + trainingSources: [], + trainingTargets: [], + draftingSources: [{ projectRef: 'different-source-id' } as DraftSource] + }); + + expect(env.logicHandler.status$.getValue()).toBe('abort'); + expect(env.logicHandler.abortMode).toBe('config_changed'); + }); + + it('does not abort when sources re-emit without changing', async () => { + const initialSources: DraftSourcesAsArrays = { + trainingSources: [], + trainingTargets: [], + draftingSources: [{ projectRef: 'draft-source-1-id' } as DraftSource] + }; + const sources$ = new BehaviorSubject(initialSources); + const env = new TestEnvironment(teamStartingToTranslateGenesis, sources$); + await env.waitForInit(); + + sources$.next({ ...initialSources }); + + expect(env.logicHandler.status$.getValue()).toBe('input'); + }); + + it('does not abort when a non-config field changes but the source set is unchanged', async () => { + const sources$ = new BehaviorSubject({ + trainingSources: [], + trainingTargets: [], + draftingSources: [{ projectRef: 'draft-source-1-id', name: 'Original' } as DraftSource] + }); + const env = new TestEnvironment(teamStartingToTranslateGenesis, sources$); + await env.waitForInit(); + + // Same project ref, but other fields (name, sync status, content) changed (e.g. after a sync). Source set is + // unchanged, so this must not abort (content/sync changes are handled separately). + sources$.next({ + trainingSources: [], + trainingTargets: [], + draftingSources: [{ projectRef: 'draft-source-1-id', name: 'Renamed' } as DraftSource] + }); + + expect(env.logicHandler.status$.getValue()).toBe('input'); + }); + + it('initializes available ranges based on progress service', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // Ranges availble for selection should be set automatically upon init + expect(env.availableDraftingScriptureRange).toBe(testState.draftingSourceBooksChapters); + expect(env.availableTargetTrainingScriptureRange).toBe(testState.targetProjectBooksChapters); + + // Selections should be blank + expect(env.selectedDraftingScriptureRange).toBe(''); + expect(env.selectedTargetTrainingScriptureRange).toBe(''); + }); + }); + + describe('books offered for drafting', () => { + it('never offers extra-material (non-canonical) books, even when the source reports content for them', async () => { + // The drafting source has content for the front matter and a glossary, alongside Genesis. + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + draftingSourceBooksChapters: 'FRT1;GEN1-50;GLO1' + }); + await env.waitForInit(); + + expect(env.availableDraftingScriptureRange).toBe('GEN1-50'); + // Non-canonical exclusions are tracked but not surfaced to the user. + expect(env.excludedDraftingBooks).toEqual( + jasmine.arrayWithExactContents([ + { bookId: 'FRT', reason: 'non_canonical' }, + { bookId: 'GLO', reason: 'non_canonical' } + ]) + ); + }); + + it('does not offer source books missing from the target when drafting books not in the target is disallowed', async () => { + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + allowDraftingBooksNotInTarget: false, + draftingSourceBooksChapters: 'GEN1-50;EXO1-40;MAT1-28', + targetTextBooks: ['GEN', 'MAT'] + }); + await env.waitForInit(); + + // EXO has source content but isn't in the target's text list, so it isn't offered. + expect(env.availableDraftingScriptureRange).toBe('GEN1-50;MAT1-28'); + expect(env.excludedDraftingBooks).toContain({ bookId: 'EXO', reason: 'not_in_target' }); + }); + + it('offers source books missing from the target when drafting books not in the target is allowed', async () => { + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + allowDraftingBooksNotInTarget: true, + draftingSourceBooksChapters: 'GEN1-50;EXO1-40;MAT1-28', + targetTextBooks: ['GEN', 'MAT'] + }); + await env.waitForInit(); + + // The same EXO book is now offered, since target membership is no longer required. + expect(env.availableDraftingScriptureRange).toBe('GEN1-50;EXO1-40;MAT1-28'); + expect(env.excludedDraftingBooks).not.toContain({ bookId: 'EXO', reason: 'not_in_target' }); + }); + + it('reports target books the drafting source has no content for', async () => { + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + allowDraftingBooksNotInTarget: false, + draftingSourceBooksChapters: 'GEN1-50', + targetTextBooks: ['GEN', 'EXO'] + }); + await env.waitForInit(); + + // EXO is in the target but the drafting source has no text for it. + expect(env.availableDraftingScriptureRange).toBe('GEN1-50'); + expect(env.excludedDraftingBooks).toContain({ bookId: 'EXO', reason: 'no_source_content' }); + }); + }); + + describe('books offered for training', () => { + it('excludes extra-material (non-canonical) books from the target and training-source lists', async () => { + // The target and a training source both report content for a glossary (extra-material). + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + targetProjectBooksChapters: 'GEN1-5;GLO1;MAT1-28', + trainingSourcesBooksChapters: { + 'training-source-1-id': 'GEN1-50;GLO1;MAT1-28' + } + }); + await env.waitForInit(); + + // The glossary is offered neither as a target training book nor as a training-source book. + expect(env.logicHandler.availableTargetTrainingScriptureRange.books.has('GLO')).toBe(false); + expect(env.logicHandler.trainingSourceBooks['training-source-1-id']).not.toContain('GLO'); + }); + + it('excludes target books absent from every training source and tracks them for the notice', async () => { + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + targetProjectBooksChapters: 'GEN1-5;MAT1-28;LUK1-24', + // The training source has Genesis and Matthew, but not Luke. + trainingSourcesBooksChapters: { 'training-source-1-id': 'GEN1-50;MAT1-28' } + }); + await env.waitForInit(); + env.logicHandler.setInputMode('training_books'); + + const availableTargetTraining = env.logicHandler.availableTargetTrainingScriptureRange; + // Genesis and Matthew remain available; Luke is withheld because no training source contains it. + expect(availableTargetTraining.books.has('GEN')).toBe(true); + expect(availableTargetTraining.books.has('MAT')).toBe(true); + expect(availableTargetTraining.books.has('LUK')).toBe(false); + expect(env.logicHandler.targetTrainingBooksWithoutSource).toEqual(['LUK']); + }); + }); + + describe('selectDraftingBooks', () => { + it('allows selecting books and chapters within the available ranges', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // Select Genesis for drafting + env.logicHandler.selectDraftingBooks(['GEN']); + // Remaining undrafted chapters automatically selected + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + }); + + it('offers to draft a subset of chapters of a partially completed book and defaults to the remaining undrafted chapters', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + + expect(env.booksOfferedForPartialDrafting).toEqual(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + }); + + it('does not offer to draft a subset of chapters of a book that has not been started', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['EXO']); + + expect(env.selectedDraftingScriptureRange).toBe('EXO1-40'); + expect(env.booksOfferedForPartialDrafting).toEqual([]); + }); + + it('does not offer or silently apply partial drafting to a book that is not eligible for it', async () => { + // Ruth has 4 source chapters (below the 12-chapter partial-drafting threshold) and the target already has + // chapters 1-2. Ruth is therefore not eligible for partial drafting, so no chapter input is shown for it. + // Selecting it must default to the whole book, not silently to just the untranslated chapters (which the user + // would have no way to change back). + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + draftingSourceBooksChapters: 'GEN1-50;RUT1-4', + targetProjectBooksChapters: 'GEN1-5;RUT1-2' + }); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['RUT']); + + expect(env.booksOfferedForPartialDrafting).not.toContain('RUT'); + expect(env.selectedDraftingScriptureRange).toBe('RUT1-4'); + }); + + it('offers to draft a subset of chapters of a book that has been completed and defaults to all chapters', async () => { + const testState = { + ...teamStartingToTranslateGenesis, + targetProjectBooksChapters: teamStartingToTranslateGenesis.targetProjectBooksChapters.replace( + 'GEN1-5', + 'GEN1-50' + ) + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + + expect(env.booksOfferedForPartialDrafting).toEqual(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN1-50'); + }); + + it('allows selecting multiple books for drafting and only offers to draft subsets of books that have some progress', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // User selects GEN and then EXO + env.logicHandler.selectDraftingBooks(['GEN']); + env.logicHandler.selectDraftingBooks(['GEN', 'EXO']); + + // User should be able to select both GEN and EXO for drafting, but only GEN should be offered for partial drafting since EXO has no progress + expect(env.booksOfferedForPartialDrafting).toEqual(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50;EXO1-40'); + }); + + it('defaults to remaining undrafted chapters when re-selecting a book after visiting the training step', async () => { + // Target has all 50 chapters of Genesis - nothing left to draft + const testState = { + ...teamStartingToTranslateGenesis, + targetProjectBooksChapters: 'GEN1-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21' + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // Selecting GEN defaults to all 50 chapters (source - target = 0 -> fallback to all) + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN1-50'); + + // User manually narrows the selection to just GEN1-30 + env.logicHandler.selectDraftingChapters('GEN', '1-30'); + expect(env.selectedDraftingScriptureRange).toBe('GEN1-30'); + + // Visit training step - GEN1-30 is subtracted from the available target training range + env.logicHandler.setInputMode('training_books'); + expect(env.availableTargetTrainingScriptureRange).toBe('GEN31-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21'); + env.logicHandler.setInputMode('draft_books'); + + // Deselect then re-select GEN - default should be all 50 chapters since target has all of them + env.logicHandler.selectDraftingBooks([]); + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN1-50'); + }); + }); + + describe('selectDraftingChapters', () => { + it('allows selecting which chapters in a book should be drafted when offering partial drafting of a book', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // User selects GEN for drafting, which offers partial drafting since GEN1-5 is completed + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.booksOfferedForPartialDrafting).toEqual(['GEN']); + + // Expect the chapter range to be set by default to the chapters that are not completed + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + + // User selects to draft a different range of chapters in GEN + env.logicHandler.selectDraftingChapters('GEN', '7,20-50'); + expect(env.selectedDraftingScriptureRange).toBe('GEN7,20-50'); + }); + + it('throws for an invalid chapter range string', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + + expect(() => env.logicHandler.selectDraftingChapters('GEN', 'abc')).toThrow(); + }); + + it('throws when selected chapters are outside the available source range', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + + // GEN only has 50 chapters in the source + expect(() => env.logicHandler.selectDraftingChapters('GEN', '51-60')).toThrow(); + }); + + it('throws when the book is not eligible for partial drafting', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + // EXO has no target progress, so partial drafting is not offered + env.logicHandler.selectDraftingBooks(['EXO']); + expect(env.booksOfferedForPartialDrafting).toEqual([]); + + expect(() => env.logicHandler.selectDraftingChapters('EXO', '1-10')).toThrow(); + }); + }); + + describe('partial-training offering tracks the drafting selection', () => { + // A book is only offered for partial target training while it is itself being drafted; otherwise the whole book + // is available for training with no per-chapter restriction. This must still hold after the drafting selection + // changes and the user returns to the training step -- the offering can't be left over from the earlier selection. + it('stops offering a book for partial target training once it is no longer being drafted', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + // Draft GEN (partial: source GEN1-50, target GEN1-5) and MAT, so deselecting GEN later still leaves a valid + // drafting selection (the step can't be left with nothing selected). + env.logicHandler.selectDraftingBooks(['GEN', 'MAT']); + expect(env.booksOfferedForPartialDrafting).toEqual(['GEN', 'MAT']); + + // On the training step, select GEN to train on (it is offered for partial target training). + env.logicHandler.setInputMode('training_books'); + env.logicHandler.selectTargetTrainingBooks(['GEN']); + expect(env.booksOfferedForPartialTargetTraining).toEqual(['GEN']); + + // Go back and drop GEN from the drafting selection (MAT remains, so the step is still valid). + env.logicHandler.setInputMode('draft_books'); + env.logicHandler.selectDraftingBooks(['MAT']); + expect(env.booksOfferedForPartialDrafting).toEqual(['MAT']); + + // Return to the training step. GEN is no longer being drafted, so it must no longer be offered for partial + // target training. + env.logicHandler.setInputMode('training_books'); + expect(env.booksOfferedForPartialTargetTraining).not.toContain('GEN'); + }); + }); + + describe('training book selection', () => { + it('defaults to previous training data when going to the training step', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // Initially no training books selected + expect(env.selectedTargetTrainingScriptureRange).toBe(''); + expect(env.selectedTrainingSourceBooks).toEqual({}); + + // Previous training selections should be selected by default when going to the training step + env.logicHandler.setInputMode('training_books'); + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MAT', 'MRK', 'LUK', 'JHN'] }); + }); + + it('does not automatically select training data that was previously selected but is no longer available in the training sources', async () => { + const testState = { + ...teamStartingToTranslateGenesis, + trainingSourcesBooksChapters: { + 'training-source-1-id': allBooksExcept(['MAT']) + } + }; + expect(testState.trainingSourcesBooksChapters['training-source-1-id']).not.toContain('MAT'); + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + // The previously selected training data included MAT, but since that's now unavailable, it should not be selected + expect(env.availableTrainingSourceBooks).toEqual({ 'training-source-1-id': ['GEN', 'MRK', 'LUK', 'JHN'] }); + expect(env.selectedTargetTrainingScriptureRange).toBe('MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MRK', 'LUK', 'JHN'] }); + }); + + it('does not automatically select training data that was previously selected but is now selected to be drafted', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['MAT']); + + env.logicHandler.setInputMode('training_books'); + + // The previously selected training data includes MAT, but since that's now selected as drafting material, it should + // not be selected as training material + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5;MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTargetTrainingScriptureRange).toBe('MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MRK', 'LUK', 'JHN'] }); + }); + + it('limits the selection of training data to what is available in the training sources and not selected for drafting', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // User selects MAT for drafting, which should remove it from the available training data since it's not possible to both draft and train on the same material + env.logicHandler.selectDraftingBooks(['MAT']); + + env.logicHandler.setInputMode('training_books'); + + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5;MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTargetTrainingScriptureRange).toBe('MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MRK', 'LUK', 'JHN'] }); + + // Go back and select Genesis instead + env.logicHandler.setInputMode('draft_books'); + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + env.logicHandler.setInputMode('training_books'); + + // Now GEN should be removed from the available training data instead of MAT + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5;MAT1-28;MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTargetTrainingScriptureRange).toBe('MRK1-16;LUK1-24;JHN1-21'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MRK', 'LUK', 'JHN'] }); + }); + + it('reallows training data to be selected when it is deselected for drafting', async () => { + const testState = teamStartingToTranslateGenesis; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + // User selects GEN and MRK for drafting, which should remove both from the available training data since it's not possible to both draft and train on the same material + env.logicHandler.selectDraftingBooks(['GEN']); + env.logicHandler.selectDraftingBooks(['GEN', 'MRK']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50;MRK1-16'); + + // Go to training step and see that MRK is not available for training + env.logicHandler.setInputMode('training_books'); + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5;MAT1-28;LUK1-24;JHN1-21'); + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;LUK1-24;JHN1-21'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MAT', 'LUK', 'JHN'] }); + + // Go back and deselect MRK for drafting + env.logicHandler.setInputMode('draft_books'); + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + + // Now MRK should be available again in the training data + env.logicHandler.setInputMode('training_books'); + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5;MAT1-28;MRK1-16;LUK1-24;JHN1-21'); + }); + + it('restores previously selected books independently for each training source', async () => { + const env = new TestEnvironment(teamWithTwoTrainingSources); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + // Each source gets its own prior selection restored, with no cross-contamination + expect(env.selectedTrainingSourceBooks).toEqual({ + 'training-source-1-id': ['MAT', 'MRK'], + 'training-source-2-id': ['LUK', 'JHN'] + }); + }); + + it("restores the target training selection from the target project's own saved entry, not from the training sources", async () => { + const testState = { + ...teamStartingToTranslateGenesis, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT;MRK;LUK;JHN' }, + // The target project's own previously selected training books, looked up by project ID + { projectId: 'testProjectId', scriptureRange: 'MAT;LUK' } + ] + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + // Target training comes from the target's own entry (MAT, LUK), not the union of the source books + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;LUK1-24'); + }); + + it('ignores chapter detail in the saved target entry and re-derives chapter defaults from current project state', async () => { + const testState = { + ...teamStartingToTranslateGenesis, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT' }, + // Chapter detail (LUK1-5) should be ignored; chapters default to all available in the current target + { projectId: 'testProjectId', scriptureRange: 'LUK1-5' } + ] + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe('LUK1-24'); + }); + + it('infers the target training selection from the source training ranges when no saved target entry exists', async () => { + // Older draft configs predate saving a target training entry, so the target selection must be inferred from the + // union of the source training books. + const testState = { + ...teamStartingToTranslateGenesis, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT;MRK;LUK;JHN' } + // No entry for the target project ('testProjectId') + ] + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;MRK1-16;LUK1-24;JHN1-21'); + }); + + it("does not treat the target project's own training entry as a training source", async () => { + const testState = { + ...teamStartingToTranslateGenesis, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT' }, + { projectId: 'testProjectId', scriptureRange: 'MAT;LUK' } + ] + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTrainingSourceBooks['testProjectId']).toBeUndefined(); + }); + + it('a book selected for drafting is excluded from all training sources', async () => { + const testState = { + ...teamWithTwoTrainingSources, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT;MRK;LUK;JHN' }, + { projectId: 'training-source-2-id', scriptureRange: 'MAT;MRK;LUK;JHN' } + ] + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['MAT']); + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTrainingSourceBooks['training-source-1-id']).not.toContain('MAT'); + expect(env.selectedTrainingSourceBooks['training-source-2-id']).not.toContain('MAT'); + }); + + it('filters a book from one source independently when it is absent from that source but present in another', async () => { + const testState = { + ...teamWithTwoTrainingSources, + previouslySelectedTrainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'MAT' }, + { projectId: 'training-source-2-id', scriptureRange: 'MAT' } + ], + trainingSourcesBooksChapters: { + 'training-source-1-id': FULL_CANON_SCRIPTURE_RANGE, + 'training-source-2-id': allBooksExcept(['MAT']) + } + }; + const env = new TestEnvironment(testState); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + // Source 1 still has MAT available and selected; source 2 does not + expect(env.availableTrainingSourceBooks['training-source-1-id']).toContain('MAT'); + expect(env.availableTrainingSourceBooks['training-source-2-id']).not.toContain('MAT'); + expect(env.selectedTrainingSourceBooks['training-source-1-id']).toContain('MAT'); + expect(env.selectedTrainingSourceBooks['training-source-2-id']).not.toContain('MAT'); + }); + }); + + describe('auto-selecting training books on first visit', () => { + // A team generating their first draft: no previously saved training selection, so training books are auto-selected. + const teamWithNoPriorTrainingSelection = { + ...teamStartingToTranslateGenesis, + previouslySelectedTrainingScriptureRanges: undefined + } as const satisfies TestState; + + it('pre-selects only books that appear complete, and pairs them in the training sources', async () => { + const env = new TestEnvironment({ + ...teamWithNoPriorTrainingSelection, + // MAT and MRK look complete; LUK and JHN do not (e.g. still in progress). + completeTargetBooks: ['MAT', 'MRK'] + }); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;MRK1-16'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MAT', 'MRK'] }); + expect(env.trainingBooksWereAutoSelected).toBe(true); + }); + + it('never pre-selects a book that is being drafted, even when it appears complete', async () => { + const env = new TestEnvironment({ + ...teamWithNoPriorTrainingSelection, + completeTargetBooks: ['GEN', 'MAT'] + }); + await env.waitForInit(); + + // Partially draft GEN (GEN6-50), leaving GEN1-5 available for training. GEN appears complete, but since the user + // is drafting it, it is a lower-conviction case and must not be auto-selected. MAT is complete and not drafted. + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28'); + expect(env.selectedTrainingSourceBooks).toEqual({ 'training-source-1-id': ['MAT'] }); + expect(env.trainingBooksWereAutoSelected).toBe(true); + }); + + it('selects nothing and shows no notice when no book appears complete', async () => { + const env = new TestEnvironment({ ...teamWithNoPriorTrainingSelection, completeTargetBooks: [] }); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe(''); + expect(env.trainingBooksWereAutoSelected).toBe(false); + }); + + it('pairs auto-selected books across every training source that contains them', async () => { + const env = new TestEnvironment({ + ...teamWithTwoTrainingSources, + previouslySelectedTrainingScriptureRanges: undefined, + completeTargetBooks: ['MAT', 'LUK'] + }); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;LUK1-24'); + expect(env.selectedTrainingSourceBooks).toEqual({ + 'training-source-1-id': ['MAT', 'LUK'], + 'training-source-2-id': ['MAT', 'LUK'] + }); + expect(env.trainingBooksWereAutoSelected).toBe(true); + }); + + it('does not auto-select when a previous training selection exists (restore takes precedence)', async () => { + // teamStartingToTranslateGenesis has a saved selection (MAT;MRK;LUK;JHN); the complete-book set should be ignored. + const env = new TestEnvironment({ ...teamStartingToTranslateGenesis, completeTargetBooks: ['GEN'] }); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + + expect(env.selectedTargetTrainingScriptureRange).toBe('MAT1-28;MRK1-16;LUK1-24;JHN1-21'); + expect(env.trainingBooksWereAutoSelected).toBe(false); + }); + + it('clears the auto-selected notice once the user deselects every target training book', async () => { + const env = new TestEnvironment({ ...teamWithNoPriorTrainingSelection, completeTargetBooks: ['MAT', 'MRK'] }); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + expect(env.trainingBooksWereAutoSelected).toBe(true); + + // Deselecting down to a non-empty selection keeps the review notice. + env.logicHandler.selectTargetTrainingBooks(['MAT']); + env.logicHandler.dismissAutoSelectNoticeIfSelectionEmpty(); + expect(env.trainingBooksWereAutoSelected).toBe(true); + + // Deselecting everything clears it. + env.logicHandler.selectTargetTrainingBooks([]); + env.logicHandler.dismissAutoSelectNoticeIfSelectionEmpty(); + expect(env.trainingBooksWereAutoSelected).toBe(false); + }); + }); + + describe('reload (after in-place sync)', () => { + it('re-derives availability from freshly fetched progress', async () => { + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + draftingSourceBooksChapters: 'GEN1-50', + targetProjectBooksChapters: 'GEN1-5', + targetProjectBooksChaptersAfterReload: 'GEN1-10' + }); + await env.waitForInit(); + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-5'); + + await env.logicHandler.reload(['testProjectId']); + + // The post-sync target content is now reflected in what's available. + expect(env.availableTargetTrainingScriptureRange).toBe('GEN1-10'); + }); + + it('defaults the draft to chapters still untranslated after the sync, not before', async () => { + const env = new TestEnvironment({ + ...teamStartingToTranslateGenesis, + draftingSourceBooksChapters: 'GEN1-50', + targetProjectBooksChapters: 'GEN1-5', + // The user synced their translation of GEN 6-10 in place. + targetProjectBooksChaptersAfterReload: 'GEN1-10', + trainingSourcesBooksChapters: { 'training-source-1-id': 'GEN1-50' } + }); + await env.waitForInit(); + + await env.logicHandler.reload(['testProjectId']); + + // Default = source - target. Pre-sync this would have been GEN6-50; with the synced chapters it must be GEN11-50, + // so the chapters the user just translated aren't defaulted back into the draft. + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN11-50'); + }); + + it('forces a fresh fetch only for the synced projects', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + resetCalls(mockedDraftProgressService); + + await env.logicHandler.reload(['testProjectId']); // only the target synced + + // Target (synced) is re-fetched with no staleness tolerance... + verify( + mockedDraftProgressService.getProgressForProject('testProjectId', deepEqual({ maxStalenessMs: 0 })) + ).once(); + // ...while the unsynced drafting source uses the default staleness (served from cache by the real service). + verify(mockedDraftProgressService.getProgressForProject('draft-source-1-id', deepEqual({}))).once(); + expect().nothing(); + }); + + it('resets any selections the user had made', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).not.toBe(''); + + await env.logicHandler.reload(['testProjectId']); + + expect(env.selectedDraftingScriptureRange).toBe(''); + expect(env.logicHandler.inputMode).toBe('draft_books'); + }); + }); + + describe('selectTargetTrainingChapters', () => { + it('updates the selected target training scripture range', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); // defaults to GEN6-50 + env.logicHandler.setInputMode('training_books'); + env.logicHandler.selectTargetTrainingBooks(['GEN']); // GEN1-5 available for training + + env.logicHandler.selectTargetTrainingChapters('GEN', '1-3'); + expect(env.selectedTargetTrainingScriptureRange).toBe('GEN1-3'); + }); + + it('throws when called in draft_books mode', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + // still in draft_books mode + expect(() => env.logicHandler.selectTargetTrainingChapters('GEN', '1-3')).toThrow(); + }); + + it('throws when selected chapters are outside the available target training range', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); // GEN6-50 drafted + env.logicHandler.setInputMode('training_books'); + env.logicHandler.selectTargetTrainingBooks(['GEN']); // only GEN1-5 available for training + + // GEN6 is being drafted, so it's not available for target training + expect(() => env.logicHandler.selectTargetTrainingChapters('GEN', '1-10')).toThrow(); + }); + + it('throws when the book is not eligible for partial target training', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + env.logicHandler.setInputMode('training_books'); + // MAT is available for training but was not offered for partial drafting + env.logicHandler.selectTargetTrainingBooks(['MAT']); + expect(env.booksOfferedForPartialTargetTraining).toEqual([]); + + expect(() => env.logicHandler.selectTargetTrainingChapters('MAT', '1-10')).toThrow(); + }); + }); + + describe('selectTargetTrainingBooks', () => { + it('updates the selected target training range', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + env.logicHandler.selectTargetTrainingBooks(['MRK', 'LUK']); + + expect(env.selectedTargetTrainingScriptureRange).toBe('MRK1-16;LUK1-24'); + }); + + it('throws when called in draft_books mode', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + expect(() => env.logicHandler.selectTargetTrainingBooks(['MAT'])).toThrow(); + }); + + it('offers partial target training for a book that was partially drafted with target chapters remaining', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + // GEN: source has 50 chapters (>= 12), target has GEN1-5 (>= 1) - eligible for partial drafting + env.logicHandler.selectDraftingBooks(['GEN']); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-50'); + + env.logicHandler.setInputMode('training_books'); + // GEN1-5 remain available for target training since only GEN6-50 is being drafted + env.logicHandler.selectTargetTrainingBooks(['GEN']); + + expect(env.booksOfferedForPartialTargetTraining).toEqual(['GEN']); + }); + + it('does not offer partial target training for books not offered for partial drafting', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.selectDraftingBooks(['GEN']); + env.logicHandler.setInputMode('training_books'); + // MAT is available for target training but was not offered for partial drafting + env.logicHandler.selectTargetTrainingBooks(['MAT']); + + expect(env.booksOfferedForPartialTargetTraining).toEqual([]); + }); + }); + + describe('selectTrainingSourceBooks', () => { + it('updates the selected training source books for a project', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + env.logicHandler.selectTrainingSourceBooks('training-source-1-id', ['MAT', 'MRK']); + + expect(env.logicHandler.selectedTrainingSourceBooks['training-source-1-id']).toEqual(['MAT', 'MRK']); + }); + + it('throws when called in draft_books mode', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + expect(() => env.logicHandler.selectTrainingSourceBooks('training-source-1-id', ['MAT'])).toThrow(); + }); + + it('throws when selecting a book not in the available set', async () => { + const env = new TestEnvironment(teamStartingToTranslateGenesis); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + // REV is not in the target project, so it can never be in the available training source books + expect(() => env.logicHandler.selectTrainingSourceBooks('training-source-1-id', ['REV'])).toThrow(); + }); + + it('preserves selections for other projects when updating one', async () => { + const env = new TestEnvironment(teamWithTwoTrainingSources); + await env.waitForInit(); + + env.logicHandler.setInputMode('training_books'); + env.logicHandler.selectTrainingSourceBooks('training-source-1-id', ['MAT']); + env.logicHandler.selectTrainingSourceBooks('training-source-2-id', ['LUK']); + + expect(env.logicHandler.selectedTrainingSourceBooks['training-source-1-id']).toEqual(['MAT']); + expect(env.logicHandler.selectedTrainingSourceBooks['training-source-2-id']).toEqual(['LUK']); + }); + }); +}); + +const mockedActivatedProjectService = mock(ActivatedProjectService); +const mockedSFProjectService = mock(SFProjectService); +const mockedDraftSourcesService = mock(DraftSourcesService); +const mockedDraftProgressService = mock(DraftProgressService); + +interface TestState { + lastSelectedTranslationScriptureRanges: ProjectScriptureRange[] | undefined; + previouslySelectedTrainingScriptureRanges: ProjectScriptureRange[] | undefined; + + /** A scripture range specifying what books and chapters exist in the drafting source */ + draftingSourceBooksChapters: string; + /** A scripture range specifying what books and chapters exist in the target project */ + targetProjectBooksChapters: string; + /** + * If set, the target progress returned by a second fetch (i.e. after `reload()`), simulating an in-place sync that + * changed the target. The first fetch still returns `targetProjectBooksChapters`. + */ + targetProjectBooksChaptersAfterReload?: string; + + /** + * A mapping of project ID to scripture range specifying what books and chapters exist in each training source, keyed + * by project ID. + */ + trainingSourcesBooksChapters: { [key: string]: string }; + noAccessSources?: boolean; + + /** + * The target books that appear complete enough to be auto-selected as training data on a first draft (what + * getCompleteBookIds reports). Independent of the chapter-level ranges above, since completeness is a segment-level + * judgment. Defaults to none. + */ + completeTargetBooks?: string[]; + + /** + * The books that exist in the target project's text list (membership, independent of content). Only consulted when + * the target-membership gate is enforced (allowDraftingBooksNotInTarget === false). Defaults to undefined (no texts). + */ + targetTextBooks?: string[]; + + /** + * Whether to allow drafting books not present in the target project. Defaults to true in tests so that the + * target-membership gate doesn't interfere with tests that aren't exercising it; tests for the gate set it + * explicitly. + */ + allowDraftingBooksNotInTarget?: boolean; +} + +class TestEnvironment { + logicHandler: NewDraftLogicHandler; + + activatedProjectService = instance(mockedActivatedProjectService); + sfProjectService = instance(mockedSFProjectService); + draftSourcesService = instance(mockedDraftSourcesService); + draftProgressService = instance(mockedDraftProgressService); + + constructor(state: TestState, sources$?: BehaviorSubject) { + // Default to allowing books not in the target so the membership gate stays out of the way of tests that aren't + // exercising it. Gate tests set this explicitly. + NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET = state.allowDraftingBooksNotInTarget ?? true; + + const project = createTestProjectProfile({ + texts: (state.targetTextBooks ?? []).map(bookId => ({ + bookNum: Canon.bookIdToNumber(bookId), + hasSource: false, + chapters: [], + permissions: {} + })), + translateConfig: { + preTranslate: true, + draftConfig: { + draftingSources: [ + { + paratextId: 'draft-source-1-pt-id', + projectRef: 'draft-source-1-id', + name: 'Draft Source 1', + shortName: 'DS1', + writingSystem: { script: 'Latn', tag: 'es' } + } + ], + trainingSources: Object.entries(state.trainingSourcesBooksChapters).map(([projectId, _booksChapters]) => ({ + paratextId: `${projectId}-pt-id`, + projectRef: projectId, + name: `Training Source for ${projectId}`, + shortName: `TS-${projectId}`, + writingSystem: { script: 'Latn', tag: 'es' } + })), + lastSelectedTrainingScriptureRanges: state.previouslySelectedTrainingScriptureRanges, + lastSelectedTrainingDataFiles: [], + lastSelectedTranslationScriptureRanges: state.lastSelectedTranslationScriptureRanges + } + } + }); + + const projectId = 'testProjectId'; + when(mockedActivatedProjectService.projectId).thenReturn(projectId); + when(mockedActivatedProjectService.projectId$).thenReturn(of(projectId)); + when(mockedActivatedProjectService.projectDoc).thenReturn({ data: project } as SFProjectProfileDoc); + when(mockedActivatedProjectService.projectDoc$).thenReturn(of({ data: project } as SFProjectProfileDoc)); + when(mockedSFProjectService.getProfile(projectId)).thenResolve({ data: project } as SFProjectProfileDoc); + + when(mockedDraftSourcesService.getDraftProjectSources()).thenReturn( + sources$ ?? + of({ + trainingSources: state.noAccessSources ? ([{ noAccess: true }] as unknown as DraftSource[]) : [], + trainingTargets: [], + draftingSources: [] + }) + ); + + // Set up the progress service to return the specified scripture ranges for the project and sources. The second + // argument (staleness options) is matched with anything() since callers pass per-project staleness overrides. + if (state.targetProjectBooksChaptersAfterReload != null) { + // First load returns the pre-sync target; a subsequent fetch (after reload) returns the post-sync target. + when(mockedDraftProgressService.getProgressForProject(projectId, anything())) + .thenResolve(new VerboseScriptureRange(state.targetProjectBooksChapters)) + .thenResolve(new VerboseScriptureRange(state.targetProjectBooksChaptersAfterReload)); + } else { + when(mockedDraftProgressService.getProgressForProject(projectId, anything())).thenResolve( + new VerboseScriptureRange(state.targetProjectBooksChapters) + ); + } + when(mockedDraftProgressService.getProgressForProject('draft-source-1-id', anything())).thenResolve( + new VerboseScriptureRange(state.draftingSourceBooksChapters) + ); + for (const [trainingSourceProjectId, booksChapters] of Object.entries(state.trainingSourcesBooksChapters)) { + when(mockedDraftProgressService.getProgressForProject(trainingSourceProjectId, anything())).thenResolve( + new VerboseScriptureRange(booksChapters) + ); + } + when(mockedDraftProgressService.getCompleteBookIds(projectId, anything())).thenResolve( + new Set(state.completeTargetBooks ?? []) + ); + + this.logicHandler = new NewDraftLogicHandler( + this.activatedProjectService, + this.draftSourcesService, + this.draftProgressService, + mockDestroyRef + ); + } + + async waitForInit(): Promise { + await firstValueFrom(this.logicHandler.status$.pipe(filter(status => status === 'input'))); + } + + async waitForAbort(): Promise { + await firstValueFrom(this.logicHandler.status$.pipe(filter(status => status === 'abort'))); + } + + // Aliases + + get availableTrainingSourceBooks(): { [projectId: string]: string[] } { + return this.logicHandler.availableTrainingSourceBooks; + } + + get selectedTrainingSourceBooks(): { [projectId: string]: string[] } { + return this.logicHandler.selectedTrainingSourceBooks; + } + + get availableTargetTrainingScriptureRange(): string { + return this.logicHandler.availableTargetTrainingScriptureRange.toString(); + } + + get selectedTargetTrainingScriptureRange(): string { + return this.logicHandler.selectedTargetTrainingScriptureRange.toString(); + } + + get trainingBooksWereAutoSelected(): boolean { + return this.logicHandler.trainingBooksWereAutoSelected; + } + + get availableDraftingScriptureRange(): string { + return this.logicHandler.availableDraftingScriptureRange.toString(); + } + + get excludedDraftingBooks(): ExcludedDraftingBook[] { + return this.logicHandler.excludedDraftingBooks; + } + + get selectedDraftingScriptureRange(): string { + return this.logicHandler.selectedDraftingScriptureRange.toString(); + } + + get booksOfferedForPartialDrafting(): string[] { + return this.logicHandler.booksOfferedForPartialDrafting; + } + + get booksOfferedForPartialTargetTraining(): string[] { + return this.logicHandler.booksOfferedForPartialTargetTraining; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.ts new file mode 100644 index 00000000000..8f2e4af8428 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft-logic-handler.ts @@ -0,0 +1,699 @@ +import { DestroyRef, Injectable } from '@angular/core'; +import { Canon } from '@sillsdev/scripture'; +import { mapValues } from 'lodash-es'; +import { BehaviorSubject, firstValueFrom, skip } from 'rxjs'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; +import { + bookAppearsCompleteForTrainingAutoSelection, + ProgressService +} from '../../../shared/progress-service/progress.service'; +import { ChapterSet, VerboseScriptureRange } from '../../../shared/scripture-range'; +import { projectLabel } from '../../../shared/utils'; +import { DraftSourcesAsArrays } from '../draft-source'; +import { DraftSourcesService } from '../draft-sources.service'; + +/** + * Minimum number of chapters a source book must have before it is offered for partial (chapter-level) drafting. + * Books smaller than this are only ever drafted in full. + */ +const MIN_SOURCE_CHAPTERS_FOR_PARTIAL_DRAFTING = 12; + +/** + * Minimum fraction of a chapter's verse segments that must be non-blank for the chapter to count as having content. + * Chapters at or below this ratio are treated as untranslated, which drives three decisions off the same policy: + * whether a source chapter is offered as material to draft from, whether a target chapter counts toward existing + * content (and so is excluded from the default drafting selection), and whether a book is eligible for partial + * drafting. Kept in one place, behind `chapterHasContent`, so those uses can't drift apart. + */ +const MIN_CHAPTER_COMPLETION_RATIO_FOR_CONTENT = 0.1; + +/** Whether a chapter has enough non-blank verse segments to count as having content (see the constant above). */ +function chapterHasContent(chapter: { verseSegments: number; blankVerseSegments: number }): boolean { + if (chapter.verseSegments === 0) { + return false; + } + const completionRatio = (chapter.verseSegments - chapter.blankVerseSegments) / chapter.verseSegments; + return completionRatio > MIN_CHAPTER_COMPLETION_RATIO_FOR_CONTENT; +} + +/** + * Why a book that a user might expect to see was left out of the list offered for drafting. Every excluded book is + * recorded with its reason so the UI can explain the omission. Not every reason is surfaced to the user: 'non_canonical' + * books (front/back matter, glossaries, etc.) are excluded silently, since users don't expect them to be draftable. + */ +export type DraftingBookExclusionReason = 'non_canonical' | 'no_source_content' | 'not_in_target'; + +/** + * Default freshness window for progress lookups. Progress data older than this is re-fetched. Callers that must have + * up-to-the-moment data (e.g. just after an in-place sync) pass `maxStalenessMs: 0` to force a fresh fetch. + */ +const DEFAULT_PROGRESS_STALENESS_MS = 1000 * 60; + +export interface ExcludedDraftingBook { + bookId: string; + reason: DraftingBookExclusionReason; +} + +@Injectable({ providedIn: 'root' }) +/** Like ProgressService, but provides a VerboseScriptureRange instead of raw progress data */ +export class DraftProgressService { + constructor(private readonly progressService: ProgressService) {} + + async getProgressForProject( + projectId: string, + options: { maxStalenessMs?: number } = {} + ): Promise { + const progress = await this.progressService.getProgressWithChapterProgress(projectId, { + maxStalenessMs: options.maxStalenessMs ?? DEFAULT_PROGRESS_STALENESS_MS + }); + const scriptureRange = new VerboseScriptureRange(); + for (const bookProgress of progress.books) { + const chapters = new ChapterSet([]); + for (const chapterProgress of bookProgress.chapters) { + if (chapterHasContent(chapterProgress)) { + chapters.chapters.add(chapterProgress.chapterNumber); + } + } + // Only include books with content; empty books shouldn't be offered for selection. + if (chapters.count() > 0) { + scriptureRange.books.set(bookProgress.bookId, chapters); + } + } + return scriptureRange; + } + + /** + * Returns the IDs of the books in a project that appear complete enough to be auto-selected as training data (see + * bookAppearsCompleteForTrainingAutoSelection). Derived from the segment-level progress counts that getProgressFor + * Project discards, which is why this is computed separately. Reuses the cached progress, so calling it alongside + * getProgressForProject for the same project (with the same staleness) costs no extra request. + */ + async getCompleteBookIds(projectId: string, options: { maxStalenessMs?: number } = {}): Promise> { + const progress = await this.progressService.getProgressWithChapterProgress(projectId, { + maxStalenessMs: options.maxStalenessMs ?? DEFAULT_PROGRESS_STALENESS_MS + }); + const completeBookIds = new Set(); + for (const bookProgress of progress.books) { + if (bookAppearsCompleteForTrainingAutoSelection(bookProgress)) { + completeBookIds.add(bookProgress.bookId); + } + } + return completeBookIds; + } +} + +export type NewDraftAbortMode = 'config_changed' | 'project_syncing' | 'no_access' | 'init_failure' | null; + +/** + * Returns the book IDs in a ScriptureRange, dropping the chapter-level detail. Useful when only the set of books + * matters, such as determining which books users can select. Books with no chapters are already pruned when ranges are + * built (see getProgressForProject and VerboseScriptureRange.removeEmptyBooks), so every returned book is selectable. + */ +export function scriptureRangeToBookListWithoutChapterDetail(range: VerboseScriptureRange): string[] { + return Array.from(range.books.keys()); +} + +/** + * Returns a copy of the range with extra-material (non-canonical) books removed. Such books (front/back matter, + * glossaries, etc.) are never drafted or used as training data, so they should not be offered for selection. + */ +function withoutExtraMaterialBooks(range: VerboseScriptureRange): VerboseScriptureRange { + const result = range.clone(); + for (const bookId of result.books.keys()) { + if (Canon.isExtraMaterial(bookId)) { + result.books.delete(bookId); + } + } + return result; +} + +/** + * Implements business logic for creating a new draft. Intended to be used in conjunction with a component that handles + * UI interaction. + * + * Basic flow is: + * 1. Initilization: Loads project and progress data, determines which books and chapters are available for drafting and + * training, and sets up subscriptions to watch background changes that would necessitate forcing the user to start + * over. The draft source and target projects are tracked at a chapter level, while the training sources are only + * tracked at a book level. + * + */ +export class NewDraftLogicHandler { + /** + * When false (current behavior, matching the legacy stepper), a book is only offered for drafting if it also exists + * in the target project's text list. This is a temporary restriction: the current UI doesn't handle drafting a book + * that isn't already in the target. SF-3822 is intended to lift this soon, at which point this can be changed to true + * (or deleted), and any canonical book with source content is offered regardless of target membership. + * + * Overridable on the class (NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET) so tests can exercise both branches. + */ + static ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET = false; + + status$ = new BehaviorSubject<'init' | 'input' | 'abort'>('init'); + abortMode: NewDraftAbortMode = null; + + /** Names of the projects that could not be accessed, populated when aborting with mode 'no_access'. */ + inaccessibleProjectNames: string[] = []; + /** The error that caused an 'init_failure' abort, retained so the component can report it. */ + initError?: unknown; + + // A book can be present (in a project), available (logic rules do not forbit selecting it, and it is therefore + // offered in the UI), and selected (user action, or default values selected the ) + availableDraftingScriptureRange: VerboseScriptureRange = new VerboseScriptureRange(); + selectedDraftingScriptureRange: VerboseScriptureRange = new VerboseScriptureRange(); + + /** Books left out of the drafting list, with the reason for each (see DraftingBookExclusionReason). */ + excludedDraftingBooks: ExcludedDraftingBook[] = []; + + targetProjectScriptureRange = new VerboseScriptureRange(); + availableTargetTrainingScriptureRange: VerboseScriptureRange = new VerboseScriptureRange(); + selectedTargetTrainingScriptureRange: VerboseScriptureRange = new VerboseScriptureRange(); + + /** + * Whether training books were automatically selected on this project's first draft (no previously saved training + * selection). Used to show the "review the pre-selected books" notice on the training step. + */ + trainingBooksWereAutoSelected: boolean = false; + + /** Target books that appear complete enough to auto-select as training data (see getCompleteBookIds). */ + private completeTargetBookIds = new Set(); + + /** + * Target books that have content available for training but are not offered, because no training source contains + * the book (so it could never be paired with a source). Populated when the training step is entered; used to explain + * why those books are missing from the target training list. + */ + targetTrainingBooksWithoutSource: string[] = []; + + /** Books that exist in the training sources, by project ID */ + trainingSourceBooks: { [projectId: string]: string[] } = {}; + availableTrainingSourceBooks: { [projectId: string]: string[] } = {}; + selectedTrainingSourceBooks: { [projectId: string]: string[] } = {}; + + get booksOfferedForPartialDrafting(): string[] { + return Array.from(this.selectedDraftingScriptureRange.books.keys()).filter(bookId => + this.isBookEligibleForPartialDrafting(bookId) + ); + } + + get booksOfferedForPartialTargetTraining(): string[] { + // Only books offered for partial drafting can have their target training chapters selected individually, and at + // least one target chapter must remain available for training. + const booksOfferedForPartialDrafting = new Set(this.booksOfferedForPartialDrafting); + return Array.from(this.selectedTargetTrainingScriptureRange.books.keys()).filter(bookId => { + if (!booksOfferedForPartialDrafting.has(bookId)) return false; + const chaptersAvailableForTraining = this.availableTargetTrainingScriptureRange.books.get(bookId); + return chaptersAvailableForTraining != null && chaptersAvailableForTraining.count() >= 1; + }); + } + + /** + * SPecifies what input mode the user is using. When a book is selected for use as drafting, it must be automatically + * removed from being used as training. However, if a user selects and unselects a book while selecting books to + * draft, that book shouldn't be automatically removed from being used as training data. Tracking the input state + * allows update rules to be enforced at the right point in time. + */ + inputMode: 'draft_books' | 'training_books' = 'draft_books'; + + sources?: DraftSourcesAsArrays; + + constructor( + private readonly activatedProjectService: ActivatedProjectService, + private readonly draftSourcesService: DraftSourcesService, + private readonly progressService: DraftProgressService, + private readonly destroyRef: DestroyRef + ) { + void this.init(); + } + + /** + * Sets up the state by loading the project, checking for changes in Paratext that haven't synced to SF yet, loading + * progress data, and setting up subscripts that watch for changes that should result in bailing out (forcing the + * user to restart the process). Automatically sets training books to most recently selected training books. + */ + async init(): Promise { + try { + const [bundle, sources] = await Promise.all([ + this.fetchProgressBundle(), + firstValueFrom(this.draftSourcesService.getDraftProjectSources()) + ]); + this.sources = sources; + + const sourcesWithNoAccess = [ + ...this.sources.trainingSources, + ...this.sources.trainingTargets, + ...this.sources.draftingSources + ].filter(source => source.noAccess); + if (sourcesWithNoAccess.length > 0) { + this.inaccessibleProjectNames = sourcesWithNoAccess + .map(source => projectLabel(source)) + .filter(label => label !== ''); + this.abort('no_access'); + return; + } + + this.applyDerivedRanges(bundle); + + this.status$.next('input'); + + // Watch for changes to which projects are configured (not their content). A source-identity change means + // the wizard was operating on a stale premise and must abort. Skip the initial emission (baseline). + const baselineSignature = this.sourceConfigSignature(this.sources); + this.draftSourcesService + .getDraftProjectSources() + .pipe(skip(1), quietTakeUntilDestroyed(this.destroyRef)) + .subscribe(newSources => { + if (this.sourceConfigSignature(newSources) !== baselineSignature) { + this.abort('config_changed'); + } + }); + } catch (error) { + // Any unanticipated failure while loading project/progress data (network errors, missing config, etc.) aborts + // into a generic failure state rather than leaving the wizard stuck on its loading spinner. The error is + // retained so the component can route it to the global error handler. + this.initError = error; + this.abort('init_failure'); + } + } + + /** + * Loads the progress data needed to derive book/chapter availability: the drafting source's content, the target's + * content, each training source's content, and the set of target books that appear complete (for auto-selection). + * Projects in `freshProjectIds` are fetched with no staleness tolerance (forcing a fresh fetch — used after an + * in-place sync); all others use the default freshness window, so unchanged projects are served from cache. + */ + private async fetchProgressBundle(freshProjectIds: Set = new Set()): Promise<{ + draftSourceProgress: VerboseScriptureRange; + targetProjectProgress: VerboseScriptureRange; + trainingSourcesProgress: { projectId: string; range: VerboseScriptureRange }[]; + completeTargetBookIds: Set; + }> { + const projectId = await firstValueFrom(this.activatedProjectService.projectId$.pipe(filterNullish())); + const projectDoc = await firstValueFrom(this.activatedProjectService.projectDoc$.pipe(filterNullish())); + + if (projectId == null) throw new Error('No project selected'); + if (projectDoc?.data == null) throw new Error('Project data not loaded'); + + const draftConfig = projectDoc.data.translateConfig?.draftConfig; + if (draftConfig == null) throw new Error('Draft config not found in project data'); + + // The UI never allows configuring more than one drafting source, so this is treated as an impossible state. + if (draftConfig.draftingSources.length !== 1) { + throw new Error(`Expected exactly one drafting source; found ${draftConfig.draftingSources.length}`); + } + const draftingSource = draftConfig.draftingSources[0]; + + const staleness = (id: string): { maxStalenessMs?: number } => + freshProjectIds.has(id) ? { maxStalenessMs: 0 } : {}; + + const trainingSourcesProgressPromise = Promise.all( + draftConfig.trainingSources.map(async trainingSource => { + const range = await this.progressService.getProgressForProject( + trainingSource.projectRef, + staleness(trainingSource.projectRef) + ); + return { projectId: trainingSource.projectRef, range }; + }) + ); + + // Order matters: the target's range (getProgressForProject) is invoked before its complete-book set + // (getCompleteBookIds) so the two reads of the same project coalesce onto a single in-flight request. + const [draftSourceProgress, targetProjectProgress, trainingSourcesProgress, completeTargetBookIds] = + await Promise.all([ + this.progressService.getProgressForProject(draftingSource.projectRef, staleness(draftingSource.projectRef)), + this.progressService.getProgressForProject(projectId, staleness(projectId)), + trainingSourcesProgressPromise, + this.progressService.getCompleteBookIds(projectId, staleness(projectId)) + ]); + + return { draftSourceProgress, targetProjectProgress, trainingSourcesProgress, completeTargetBookIds }; + } + + /** + * Derives the offered drafting books, available ranges, and training-source book lists from a freshly loaded progress + * bundle and publishes them on the relevant subjects. Reads the target's text list from the (live) project doc for + * membership, so a reload after an in-place sync picks up newly synced books. Does not touch the user's selections. + */ + private applyDerivedRanges(bundle: { + draftSourceProgress: VerboseScriptureRange; + targetProjectProgress: VerboseScriptureRange; + trainingSourcesProgress: { projectId: string; range: VerboseScriptureRange }[]; + completeTargetBookIds: Set; + }): void { + const texts = this.activatedProjectService.projectDoc?.data?.texts ?? []; + const targetTextBookIds = new Set(texts.map(text => Canon.bookNumberToId(text.bookNum))); + const { available, excluded } = this.computeOfferedDraftingBooks(bundle.draftSourceProgress, targetTextBookIds); + + // Extra-material books are never offered for training (drafting handles them separately). + const canonicalTargetProgress = withoutExtraMaterialBooks(bundle.targetProjectProgress); + + this.completeTargetBookIds = bundle.completeTargetBookIds; + this.targetProjectScriptureRange = canonicalTargetProgress; + this.availableDraftingScriptureRange = available; + this.excludedDraftingBooks = excluded; + // Clone: this range is narrowed in limitAvailableTrainingRangeBasedOnSelectedDraftingRange, so it must not alias + // targetProjectScriptureRange. + this.availableTargetTrainingScriptureRange = canonicalTargetProgress.clone(); + this.trainingSourceBooks = Object.fromEntries( + bundle.trainingSourcesProgress.map(source => [ + source.projectId, + scriptureRangeToBookListWithoutChapterDetail(withoutExtraMaterialBooks(source.range)) + ]) + ); + } + + /** + * Re-derives book/chapter availability after the user syncs stale projects in place via the pending-updates + * pre-step. Only the projects in `syncedProjectIds` are re-fetched fresh; unchanged projects are served from cache. + * Valid only before the user has made any selection (the pre-step precedes Step 1), so it returns the selection + * state to its post-init baseline rather than trying to preserve stale selections. + */ + async reload(syncedProjectIds: string[]): Promise { + const bundle = await this.fetchProgressBundle(new Set(syncedProjectIds)); + this.applyDerivedRanges(bundle); + this.resetSelectionState(); + } + + /** Returns all selection-derived state to its post-init baseline. */ + private resetSelectionState(): void { + this.selectedDraftingScriptureRange = new VerboseScriptureRange(); + this.selectedTargetTrainingScriptureRange = new VerboseScriptureRange(); + this.selectedTrainingSourceBooks = {}; + this.availableTrainingSourceBooks = {}; + this.targetTrainingBooksWithoutSource = []; + this.trainingBooksWereAutoSelected = false; + this.hasVisitedTrainingBooksInputMode = false; + this.inputMode = 'draft_books'; + } + + private hasVisitedTrainingBooksInputMode = false; + setInputMode(newMode: 'draft_books' | 'training_books'): void { + const priorMode = this.inputMode; + // Switch the mode first so that loadPreviouslySelectedTrainingBooks() can use the normal training-book selection + // path (e.g. selectTargetTrainingBooks), which requires being in training_books mode. + this.inputMode = newMode; + if (priorMode === 'draft_books' && newMode === 'training_books') { + this.limitAvailableTrainingRangeBasedOnSelectedDraftingRange(); + if (!this.hasVisitedTrainingBooksInputMode) { + this.loadPreviouslySelectedTrainingBooks(); + this.hasVisitedTrainingBooksInputMode = true; + } + } + } + + selectDraftingBooks(books: string[]): void { + if (this.inputMode !== 'draft_books') { + throw new Error('Cannot update draft books when not in draft_books input mode'); + } + + const newDraftingScriptureRange = new VerboseScriptureRange(); + const newlySelectedBooks = books.filter(book => !this.selectedDraftingScriptureRange.books.has(book)); + + for (const book of books) { + if (!this.availableDraftingScriptureRange.books.has(book)) { + throw new Error(`Selected book ${book} not in available drafting scripture range`); + } + if (newlySelectedBooks.includes(book)) { + const chaptersInTarget = this.targetProjectScriptureRange.books.get(book); + const chaptersInSource = this.availableDraftingScriptureRange.books.get(book); + if (chaptersInSource == null) + throw new Error(`Selected book ${book} not in available drafting scripture range`); + // Only books eligible for partial drafting get a chapter input, so only they may default to a subset. + // Default an eligible book to the untranslated chapters (those in the source but not the target), falling + // back to the whole book when none remain untranslated. A book that is not eligible has no input, so it must + // default to the whole book; defaulting it to a subset would silently drop chapters the user couldn't add + // back. + if (this.isBookEligibleForPartialDrafting(book) && chaptersInTarget != null) { + const newChaptersToDraft = chaptersInSource.difference(chaptersInTarget); + newDraftingScriptureRange.books.set( + book, + newChaptersToDraft.count() > 0 ? newChaptersToDraft : chaptersInSource.clone() + ); + } else { + newDraftingScriptureRange.books.set(book, chaptersInSource.clone()); + } + } else { + const alreadySelectedChapters = this.selectedDraftingScriptureRange.books.get(book); + if (alreadySelectedChapters == null) throw new Error('This should be unreachable'); + newDraftingScriptureRange.books.set(book, alreadySelectedChapters); + } + } + this.selectedDraftingScriptureRange = newDraftingScriptureRange; + } + + selectDraftingChapters(bookId: string, chapters: string): void { + if (this.inputMode !== 'draft_books') { + throw new Error('Cannot update draft books when not in draft_books input mode'); + } + + const selectedChapters = ChapterSet.fromUserInput(chapters); + + if (!this.booksOfferedForPartialDrafting.includes(bookId)) { + throw new Error(`Book ${bookId} is not eligible for partial drafting`); + } + const chaptersInSource = this.availableDraftingScriptureRange.books.get(bookId); + if (chaptersInSource == null) throw new Error(`Book ${bookId} not in available drafting scripture range`); + const selectedChaptersNotInSource = selectedChapters.difference(chaptersInSource); + if (selectedChaptersNotInSource.count() > 0) { + throw new Error( + `Selected chapters ${selectedChaptersNotInSource.toString()} are not in the available drafting scripture range for book ${bookId}` + ); + } + + const newDraftingScriptureRange = this.selectedDraftingScriptureRange.clone(); + newDraftingScriptureRange.books.set(bookId, selectedChapters); + this.selectedDraftingScriptureRange = newDraftingScriptureRange; + } + + /** + * Determines which books from the drafting source are offered for drafting, and records why each book the user might + * expect to see was left out. A book is offered only if it is canonical, has content in the drafting source, and + * (unless allowDraftingBooksNotInTarget is set) exists in the target project's text list. + * + * The books considered are those with content in the drafting source plus those present in the target project. This + * lets the UI explain both books the target contains but the source has no text for ('no_source_content') and books + * the source has but the target lacks ('not_in_target'). Books that are excluded purely for being non-canonical are + * recorded as 'non_canonical' but are not surfaced to the user. + */ + private computeOfferedDraftingBooks( + draftSourceProgress: VerboseScriptureRange, + targetTextBookIds: Set + ): { available: VerboseScriptureRange; excluded: ExcludedDraftingBook[] } { + const available = new VerboseScriptureRange(); + const excluded: ExcludedDraftingBook[] = []; + + const booksToConsider = new Set([...draftSourceProgress.books.keys(), ...targetTextBookIds]); + // Evaluate in canonical order so the excluded list (and any notice built from it) reads naturally. + const orderedBooks = Array.from(booksToConsider).sort((a, b) => Canon.bookIdToNumber(a) - Canon.bookIdToNumber(b)); + + for (const bookId of orderedBooks) { + if (Canon.isExtraMaterial(bookId)) { + excluded.push({ bookId, reason: 'non_canonical' }); + } else if (!draftSourceProgress.books.has(bookId)) { + excluded.push({ bookId, reason: 'no_source_content' }); + } else if (!NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET && !targetTextBookIds.has(bookId)) { + excluded.push({ bookId, reason: 'not_in_target' }); + } else { + available.books.set(bookId, draftSourceProgress.books.get(bookId)!.clone()); + } + } + + return { available, excluded }; + } + + private isBookEligibleForPartialDrafting(bookId: string): boolean { + const sourceChapterCount = this.availableDraftingScriptureRange.books.get(bookId)?.count(); + const targetChaptersWithContent = this.targetProjectScriptureRange.books.get(bookId)?.count(); + + return ( + sourceChapterCount != null && + sourceChapterCount >= MIN_SOURCE_CHAPTERS_FOR_PARTIAL_DRAFTING && + targetChaptersWithContent != null && + targetChaptersWithContent >= 1 + ); + } + + selectTargetTrainingChapters(bookId: string, chapters: string): void { + if (this.inputMode !== 'training_books') { + throw new Error('Cannot update training chapters when not in training_books input mode'); + } + + const selectedChapters = ChapterSet.fromUserInput(chapters); + + if (!this.booksOfferedForPartialTargetTraining.includes(bookId)) { + throw new Error(`Book ${bookId} is not eligible for partial target training`); + } + const chaptersAvailableForTraining = this.availableTargetTrainingScriptureRange.books.get(bookId); + if (chaptersAvailableForTraining == null) + throw new Error(`Book ${bookId} not in available target training scripture range`); + const selectedChaptersNotAvailable = selectedChapters.difference(chaptersAvailableForTraining); + if (selectedChaptersNotAvailable.count() > 0) { + throw new Error( + `Selected chapters ${selectedChaptersNotAvailable.toString()} are not available for target training for book ${bookId}` + ); + } + + const newTargetTrainingScriptureRange = this.selectedTargetTrainingScriptureRange.clone(); + newTargetTrainingScriptureRange.books.set(bookId, selectedChapters); + this.selectedTargetTrainingScriptureRange = newTargetTrainingScriptureRange; + } + + selectTrainingSourceBooks(projectId: string, bookIds: string[]): void { + if (this.inputMode !== 'training_books') { + throw new Error('Cannot update training source books when not in training_books input mode'); + } + const available = this.availableTrainingSourceBooks[projectId] ?? []; + for (const bookId of bookIds) { + if (!available.includes(bookId)) { + throw new Error(`Selected book ${bookId} is not available for training source project ${projectId}`); + } + } + const current = { ...this.selectedTrainingSourceBooks }; + current[projectId] = bookIds; + this.selectedTrainingSourceBooks = current; + } + + selectTargetTrainingBooks(books: string[]): void { + if (this.inputMode !== 'training_books') { + throw new Error('Cannot update training books when not in training_books input mode'); + } + const newTargetTrainingScriptureRange = new VerboseScriptureRange(); + for (const bookId of books) { + const bookRange = this.availableTargetTrainingScriptureRange.books.get(bookId); + if (bookRange == null) { + throw new Error(`Selected book ${bookId} not in available target training scripture range`); + } + newTargetTrainingScriptureRange.books.set(bookId, bookRange.clone()); + } + this.selectedTargetTrainingScriptureRange = newTargetTrainingScriptureRange; + } + + /** + * A stable fingerprint of which projects are configured as drafting/training sources, used to detect mid-flow + * reconfiguration. Only the set of project refs matters (order-independent); the target is excluded since it is + * fixed (it is the activated project). Content and sync changes deliberately do not affect this signature. + */ + private sourceConfigSignature(sources: DraftSourcesAsArrays): string { + const refs = [...sources.draftingSources, ...sources.trainingSources].map(source => source.projectRef); + return JSON.stringify([...new Set(refs)].sort()); + } + + abort(mode: NewDraftAbortMode): void { + this.abortMode = mode; + this.status$.next('abort'); + } + + private loadPreviouslySelectedTrainingBooks(): void { + const draftConfig = this.activatedProjectService.projectDoc?.data?.translateConfig?.draftConfig; + if (draftConfig == null) throw new Error('Draft config not found in project data'); + const targetProjectId = this.activatedProjectService.projectId; + const lastSelectedTrainingScriptureRanges = draftConfig.lastSelectedTrainingScriptureRanges ?? []; + + // On a project's first draft there is nothing to restore; auto-select a default instead. + if (lastSelectedTrainingScriptureRanges.length === 0) { + this.autoSelectTrainingBooks(); + return; + } + + // Restore the previously selected books for each training source, ignoring the target project's own entry (handled + // separately below). Only keep books that are still available for training in that source. + const selectedTrainingSourceBooksByProjectId: { [key: string]: string[] } = {}; + for (const sourceScriptureRange of lastSelectedTrainingScriptureRanges) { + if (sourceScriptureRange.projectId === targetProjectId) continue; + const previouslySelectedBooks = scriptureRangeToBookListWithoutChapterDetail( + new VerboseScriptureRange(sourceScriptureRange.scriptureRange) + ); + const booksAvailableForTraining = this.availableTrainingSourceBooks[sourceScriptureRange.projectId] ?? []; + selectedTrainingSourceBooksByProjectId[sourceScriptureRange.projectId] = previouslySelectedBooks.filter(bookId => + booksAvailableForTraining.includes(bookId) + ); + } + this.selectedTrainingSourceBooks = selectedTrainingSourceBooksByProjectId; + + // Determine the previously selected target training books. Prefer the target project's own saved entry (looked up + // by project ID); its chapter detail is ignored so that chapter defaults are re-derived from current project + // state. Older draft configs predate saving a target entry, so when none exists fall back to inferring it from + // the union of the selected source training books (the previous behavior). + const savedTargetTrainingRange = lastSelectedTrainingScriptureRanges.find( + range => range.projectId === targetProjectId + ); + const previouslySelectedTargetBooks = + savedTargetTrainingRange != null + ? scriptureRangeToBookListWithoutChapterDetail( + new VerboseScriptureRange(savedTargetTrainingRange.scriptureRange) + ) + : Array.from(new Set(Object.values(selectedTrainingSourceBooksByProjectId).flat())); + + // Run the books through the normal book-selection path so chapter defaults match a manual selection. Filter to + // books still available for target training first, since selectTargetTrainingBooks requires available books. + const availableTargetTrainingScriptureRange = this.availableTargetTrainingScriptureRange; + const availableTargetBooks = previouslySelectedTargetBooks.filter(bookId => + availableTargetTrainingScriptureRange.books.has(bookId) + ); + this.selectTargetTrainingBooks(availableTargetBooks); + } + + /** + * Auto-selects training books on a project's first draft. Picks only books that appear fully translated (see + * getCompleteBookIds) and are not being drafted, then pairs each with its source books. Uses a high bar because the + * selection is persisted and reused and a wrong pick silently degrades future drafts. + */ + private autoSelectTrainingBooks(): void { + const availableTargetTrainingRange = this.availableTargetTrainingScriptureRange; + const draftedBooks = this.selectedDraftingScriptureRange.books; + const booksToAutoSelect = Array.from(availableTargetTrainingRange.books.keys()).filter( + bookId => this.completeTargetBookIds.has(bookId) && !draftedBooks.has(bookId) + ); + + this.selectTargetTrainingBooks(booksToAutoSelect); + + // Pair each auto-selected book with matching source books, same as a manual selection would. + const autoSelected = new Set(booksToAutoSelect); + this.selectedTrainingSourceBooks = mapValues(this.availableTrainingSourceBooks, bookIds => + bookIds.filter(bookId => autoSelected.has(bookId)) + ); + + this.trainingBooksWereAutoSelected = booksToAutoSelect.length > 0; + } + + /** Clears the auto-selected notice once the user has deselected all target training books. */ + dismissAutoSelectNoticeIfSelectionEmpty(): void { + if (this.trainingBooksWereAutoSelected && this.selectedTargetTrainingScriptureRange.books.size === 0) { + this.trainingBooksWereAutoSelected = false; + } + } + + private limitAvailableTrainingRangeBasedOnSelectedDraftingRange(): void { + // Available target training books are the target's content minus what's being drafted, further limited to books + // that exist in at least one training source: a target book can only be used as training data if a source + // provides the matching book to pair it with. Books with no such source are recorded + // (targetTrainingBooksWithoutSource) so the UI can explain why they aren't offered. + const targetTrainingRange = this.targetProjectScriptureRange.difference(this.selectedDraftingScriptureRange); + const booksInAnyTrainingSource = new Set(Object.values(this.trainingSourceBooks).flat()); + + const availableTargetTrainingRange = new VerboseScriptureRange(); + const booksWithoutSource: string[] = []; + for (const [bookId, chapters] of targetTrainingRange.books) { + if (booksInAnyTrainingSource.has(bookId)) { + availableTargetTrainingRange.books.set(bookId, chapters); + } else { + booksWithoutSource.push(bookId); + } + } + this.availableTargetTrainingScriptureRange = availableTargetTrainingRange; + this.targetTrainingBooksWithoutSource = booksWithoutSource; + this.selectedTargetTrainingScriptureRange = this.selectedTargetTrainingScriptureRange.difference( + this.selectedDraftingScriptureRange + ); + + // Limit available and selected training source books to not exceed available target training scripture range + const availableTargetRange = this.availableTargetTrainingScriptureRange; + this.availableTrainingSourceBooks = mapValues(this.trainingSourceBooks, bookIds => + bookIds.filter(bookId => availableTargetRange.books.has(bookId)) + ); + this.selectedTrainingSourceBooks = mapValues(this.selectedTrainingSourceBooks, (bookIds, projectId) => + bookIds.filter(bookId => this.availableTrainingSourceBooks[projectId]?.includes(bookId)) + ); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html new file mode 100644 index 00000000000..b606fab1b92 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html @@ -0,0 +1,367 @@ +
+ @if (page === "abort") { +
+ info + @if (abortMode === "config_changed") { +

{{ t("abort.config_changed") }}

+ } @else if (abortMode === "project_syncing") { +

{{ t("abort.project_syncing") }}

+ } @else if (inaccessibleProjectNames.length > 0) { +

{{ t("abort.no_access_list_heading") }}

+
    + @for (project of inaccessibleProjectNames; track project) { +
  • {{ project }}
  • + } +
+

{{ t("abort.no_access_instruction") }}

+ } @else { +

{{ t("abort.no_access") }}

+ } + +
+ } @else if (initData == null || page === "loading") { + +

{{ t("loading") }}

+ } @else { + @if (stepNumber != null) { +

{{ t("step_indicator", { step: stepNumber, total: totalStepCount }) }}

+ } + @if (page === "pending_updates") { + + } @else if (page === "preface") { + + @for (msg of copyrightMessages; track msg.banner) { + + } + + } @else if (page === "draft_books") { +

{{ t("draft_books.title") }}

+

+ @if (availableDraftingBooks.length === 0) { + {{ t("draft_books.no_available_books") }} + } @else { + + } + @if (draftingExclusionNotices.length > 0) { + +
+ + @if (draftingExclusionsExpanded) { + @for (notice of draftingExclusionNotices; track notice.key) { +

{{ t(notice.key, notice.params) }}

+ } + } +
+
+ } + @if (booksOfferedForPartialDrafting.length > 0) { +

{{ t("draft_books.which_chapters") }}

+

{{ t("draft_books.partial_explanation") }}

+
+ @for (bookId of booksOfferedForPartialDrafting; track bookId) { + {{ i18n.localizeBook(bookId) }} +
+ + + {{ t("chapter_input.available", { range: draftingChapterHint(bookId) }) }} + + @if (draftingChapterErrors.get(bookId); as error) { + {{ t(error.key, error.params) }} + } +
+ } +
+ } + } @else if (page === "training_books") { +

{{ t("training_books.title") }}

+

+ @if (availableTargetTrainingBooks.length === 0) { + {{ t("training_books.no_target_books") }} + } @else { + + } + @if (trainingBooksWereAutoSelected) { + {{ t("training_books.auto_selected") }} + } + @if (hasTargetTrainingBooksWithoutSource) { + +
+ + @if (trainingExclusionsExpanded) { +

+ {{ t("training_books.excluded_not_in_any_source", { books: targetTrainingBooksWithoutSourceNames }) }} +

+ } +
+
+ } + @if (booksOfferedForPartialTargetTraining.length > 0) { +

{{ t("training_books.which_chapters") }}

+

{{ t("training_books.partial_explanation") }}

+
+ @for (bookId of booksOfferedForPartialTargetTraining; track bookId) { + {{ i18n.localizeBook(bookId) }} +
+ + + {{ t("chapter_input.available", { range: targetTrainingChapterHint(bookId) }) }} + + @if (targetTrainingChapterErrors.get(bookId); as error) { + {{ t(error.key, error.params) }} + } +
+ } +
+ } + @if (trainingSources.length > 0) { +

{{ t("reference_books") }}

+ @for (source of trainingSources; track source.projectRef) { + @let availableSourceBooks = availableTrainingSourceBooksForProject(source.projectRef); + + @if (availableSourceBooks.length === 0) { +

{{ t("training_books.reference_books_will_appear") }}

+ } + } + } + @if (trainingDataFiles.length > 0) { +

{{ t("training_files") }}

+
+ @for (file of trainingDataFiles; track file.dataId) { + + {{ file.title }} + + } +
+ } + } @else if (page === "suffix") { +

{{ t("summary.draft_title", { books: draftingBookNamesFormatted }) }}

+ + + +
+
+

{{ t("summary.draft_books") }}

+

+
+ +
+
    + @for (item of draftingBookListItems; track item) { +
  • {{ item }}
  • + } +
+
+
+ + + +
+
+

{{ t("summary.example_translations") }}

+

+
+ +
+ @if (sourceTrainingSections.length === 0) { +

{{ t("summary.no_training_books") }}

+ } @else { + + + + + + + + + + @for (source of sourceTrainingSections; track source.projectRef) { + + + + + + } + +
{{ t("summary.training_books") }}{{ sourceTrainingLanguageName }}{{ targetLanguageName }}
{{ formatTrainingBooks(source.bookNumbers) }}{{ source.shortName }}{{ targetShortName }}
+ } + @if (selectedTrainingDataFileTitles.length > 0) { + + + + + + + + @for (title of selectedTrainingDataFileTitles; track title) { + + + + } + +
{{ t("training_files") }}
{{ title }}
+ } +
+
+ + + +

{{ t("summary.settings") }}

+ @if (currentUserEmail != null) { + + {{ t("summary.email_me", { email: currentUserEmail }) }} + + } +
+
+ + @if (isCustomConfigSet) { + {{ t("summary.custom_configurations_apply") }} + } + + @if (featureFlags.showDeveloperTools.enabled) { + + +

Developer options

+
+ + Fast Training - saves time but greatly reduces accuracy + +
+
+ + Echo Translation Engine - echoes source text instead of translating + +
+
+
+ } + + @if (!onlineStatusService.isOnline) { + {{ t("offline_message") }} + } + } + + @if (page !== "pending_updates") { + @if (stepError) { +

{{ t(stepError) }}

+ } +
+ + @if (page === "suffix") { +
+ @if (submitting) { +
+ } + +
+ } @else { + + } +
+ } + } +
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss new file mode 100644 index 00000000000..a7c1e8ee5e0 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.scss @@ -0,0 +1,193 @@ +@use '@angular/material' as mat; +@use 'src/variables'; + +.wrapper { + max-width: 120em; +} + +.loading-indicator { + margin: 0 auto; + top: 10em; +} + +.loading-text { + text-align: center; + color: variables.$lighterTextColor; +} + +.empty-state { + color: variables.$lighterTextColor; +} + +.abort-screen { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 1em; + max-width: 40em; + margin: 6em auto 0; + + .abort-icon { + font-size: 3em; + width: auto; + height: auto; + } + + .inaccessible-projects { + margin: 0; + text-align: start; + + li { + font-weight: 600; + } + } +} + +.partial-book-drafting-table { + display: inline-grid; + gap: 16px; + align-items: baseline; + .book-name { + grid-column-start: 1; + font-weight: 500; + } + .chapter-input-wrapper { + grid-column-start: 2; + } +} + +.configure-sources-link { + margin-top: 1em; +} + +app-confirm-sources { + --confirm-sources-h1-margin-top: 0; +} + +app-copyright-banner { + display: block; + margin-top: 1em; +} + +.step-indicator { + color: variables.$lighterTextColor; + font-size: 0.85em; + margin: 0 0 1em; +} + +.step-buttons { + display: flex; + gap: 1em; + margin-top: 1em; +} + +.chapter-input { + @include mat.form-field-density(-5); +} + +.chapter-error { + color: var(--mat-form-field-error-text-color); + padding-left: 16px; +} + +.step-error { + color: var(--mat-form-field-error-text-color); +} + +.summary-card { + margin-bottom: 1.5em; + + h1 { + font-size: 1.5em; + font-weight: 400; + margin: 0 0 0.75em; + line-height: 1.3; + } +} + +.summary-subtitle { + color: variables.$lighterTextColor; + margin: 0; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1em; + + h1 { + margin-bottom: 0.25em; + } +} + +.card-header-action { + flex-shrink: 0; +} + +.draft-books-list { + margin: 1em 0 0; +} + +.summary-table { + width: 100%; + border-collapse: collapse; + margin-top: 1.5em; + + th, + td { + text-align: start; + padding: 0.75em 0.5em; + border-bottom: 1px solid variables.$border-color; + } + + th { + font-weight: 500; + } +} + +.summary-empty { + color: variables.$greyLight; + font-style: italic; +} + +.training-data-files { + display: flex; + flex-direction: column; + gap: 0.5em; + margin-bottom: 0.875em; +} + +.save-container { + display: flex; + align-items: center; + gap: 1em; +} + +.books-hidden-container { + display: flex; + flex-direction: column; + row-gap: 0.5em; +} + +.books-hidden-message { + cursor: pointer; + + ::ng-deep u { + &:hover { + color: variables.$theme-secondary; + } + } +} + +.books-hidden-explanation { + margin: 0; +} + +:host ::ng-deep { + strong.semi-bold, + .semi-bold strong { + font-weight: 500; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts new file mode 100644 index 00000000000..c4f59932f41 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.spec.ts @@ -0,0 +1,1157 @@ +import { DestroyRef, ErrorHandler } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { Canon } from '@sillsdev/scripture'; +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 { TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data'; +import { BehaviorSubject, filter, firstValueFrom, Observable, of, Subject } from 'rxjs'; +import { anything, capture, deepEqual, instance, mock, reset, resetCalls, verify, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { createTestFeatureFlag, FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; +import { I18nService } from 'xforge-common/i18n.service'; +import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; +import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; +import { UserService } from 'xforge-common/user.service'; +import { ParatextProject } from '../../../core/models/paratext-project'; +import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; +import { ParatextService } from '../../../core/paratext.service'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { VerboseScriptureRange } from '../../../shared/scripture-range'; +import { NllbLanguageService } from '../../nllb-language.service'; +import { DraftGenerationService } from '../draft-generation.service'; +import { DraftSource } from '../draft-source'; +import { DraftSourcesService } from '../draft-sources.service'; +import { TrainingDataService } from '../training-data/training-data.service'; +import { DraftProgressService, NewDraftLogicHandler } from './new-draft-logic-handler'; +import { NewDraftComponent } from './new-draft.component'; + +const SOURCE_SHORT_NAME = 'DS1'; +const TARGET_SHORT_NAME = 'TP1'; + +const ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET_ORIGINAL_VALUE = NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET; + +describe('NewDraftComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ providers: [TestOnlineStatusService, provideTestOnlineStatus()] }); + // These tests aren't exercising the target-membership gate, and the test project has no text list, so allow + // drafting books regardless of target membership. Reset afterwards so it doesn't leak into other specs. + NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET = true; + }); + + afterEach(() => { + NewDraftLogicHandler.ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET = ALLOW_DRAFTING_BOOKS_NOT_IN_TARGET_ORIGINAL_VALUE; + }); + + // GEN: source has 50 chapters, target has GEN1-5 -> eligible for partial drafting + const testState = { + draftingSourceBooksChapters: 'GEN1-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21', + targetProjectBooksChapters: 'GEN1-5;MAT1-28;MRK1-16;LUK1-24;JHN1-21', + trainingSourcesBooksChapters: { 'training-source-1-id': 'GEN1-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21' } + }; + + describe('onDraftingChaptersBlurred', () => { + it('sets an invalid_range error for unparseable input', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + env.component.onDraftingChaptersBlurred('GEN', 'abc'); + + expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.invalid_range'); + }); + + it('rejects empty or whitespace-only input without changing the drafting selection', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + const defaultRange = env.selectedDraftingScriptureRange; + + env.component.onDraftingChaptersBlurred('GEN', ''); + expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_draft'); + + env.component.onDraftingChaptersBlurred('GEN', ' '); + expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_draft'); + + // The book keeps its prior (non-empty) range rather than being stored with zero chapters. + expect(env.selectedDraftingScriptureRange).toBe(defaultRange); + expect(env.selectedDraftingScriptureRange).not.toBe('GEN'); + }); + + // A lone separator (',' or the Arabic comma '،') is non-empty but normalizes to no chapters; it must be rejected + // like empty input rather than committed as a zero-chapter (whole-book) selection. + it('rejects separator-only input that normalizes to no chapters', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + const defaultRange = env.selectedDraftingScriptureRange; + + env.component.onDraftingChaptersBlurred('GEN', ','); + expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_draft'); + + // The Arabic comma is normalized to a list separator too, so it has the same empty result. + env.component.onDraftingChaptersBlurred('GEN', '،'); + expect(env.component.draftingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_draft'); + + // The book must keep its prior range rather than collapsing to a whole-book 'GEN' selection. + expect(env.selectedDraftingScriptureRange).toBe(defaultRange); + expect(env.selectedDraftingScriptureRange).not.toBe('GEN'); + }); + + it('sets a chapters_not_in_source error with source name for out-of-range chapters', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + // GEN has only 50 chapters in the source + env.component.onDraftingChaptersBlurred('GEN', '51-60'); + + const error = env.component.draftingChapterErrors.get('GEN'); + expect(error?.key).toBe('chapter_input.chapters_not_in_source'); + expect(error?.params).toEqual(jasmine.objectContaining({ chapters: '51-60', sourceName: SOURCE_SHORT_NAME })); + }); + + it('clears the error and updates state for valid input', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + // First introduce an error + env.component.onDraftingChaptersBlurred('GEN', 'abc'); + expect(env.component.draftingChapterErrors.has('GEN')).toBeTrue(); + + // Then provide valid input + env.component.onDraftingChaptersBlurred('GEN', '6-30'); + + expect(env.component.draftingChapterErrors.has('GEN')).toBeFalse(); + expect(env.selectedDraftingScriptureRange).toBe('GEN6-30'); + }); + }); + + describe('onTargetTrainingChaptersBlurred', () => { + it('sets an invalid_range error for unparseable input', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + await env.selectGENForTraining(); + + env.component.onTargetTrainingChaptersBlurred('GEN', 'xyz'); + + expect(env.component.targetTrainingChapterErrors.get('GEN')?.key).toBe('chapter_input.invalid_range'); + }); + + it('rejects empty or whitespace-only input without changing the target training selection', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + await env.selectGENForTraining(); + const defaultRange = env.selectedTargetTrainingScriptureRange; + + env.component.onTargetTrainingChaptersBlurred('GEN', ''); + expect(env.component.targetTrainingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_training'); + + env.component.onTargetTrainingChaptersBlurred('GEN', ' '); + expect(env.component.targetTrainingChapterErrors.get('GEN')?.key).toBe('chapter_input.empty_training'); + + expect(env.selectedTargetTrainingScriptureRange).toBe(defaultRange); + expect(env.selectedTargetTrainingScriptureRange).not.toBe('GEN'); + }); + + it('sets a chapters_will_be_translated error when selected chapters are being drafted', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + // GEN6-50 is being drafted; GEN1-5 is available for training + await env.selectGENForTraining(); + + // Trying to include GEN6 (which is being drafted) in training + env.component.onTargetTrainingChaptersBlurred('GEN', '1-10'); + + const error = env.component.targetTrainingChapterErrors.get('GEN'); + expect(error?.key).toBe('chapter_input.chapters_will_be_translated'); + expect(error?.params).toEqual(jasmine.objectContaining({ chapters: '6-10' })); + }); + + it('sets a chapters_not_in_target error when selected chapters are absent from the target project', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + // Narrow the drafted range to GEN6-10, leaving GEN11-50 neither drafted nor in target + env.component.logicHandler.selectDraftingBooks(['GEN']); + env.component.logicHandler.selectDraftingChapters('GEN', '6-10'); + env.component.logicHandler.setInputMode('training_books'); + env.component.logicHandler.selectTargetTrainingBooks(['GEN']); // GEN1-5 available + + // GEN11-15 are not in the target project and not being drafted + env.component.onTargetTrainingChaptersBlurred('GEN', '11-15'); + + const error = env.component.targetTrainingChapterErrors.get('GEN'); + expect(error?.key).toBe('chapter_input.chapters_not_in_target'); + expect(error?.params).toEqual(jasmine.objectContaining({ chapters: '11-15', targetName: TARGET_SHORT_NAME })); + }); + + it('clears the error and updates state for valid input', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + await env.selectGENForTraining(); + + // First introduce an error + env.component.onTargetTrainingChaptersBlurred('GEN', 'xyz'); + expect(env.component.targetTrainingChapterErrors.has('GEN')).toBeTrue(); + + // Then provide valid input - GEN1-3 is within the available training range GEN1-5 + env.component.onTargetTrainingChaptersBlurred('GEN', '1-3'); + + expect(env.component.targetTrainingChapterErrors.has('GEN')).toBeFalse(); + expect(env.selectedTargetTrainingScriptureRange).toBe('GEN1-3'); + }); + }); + + describe('training source book sync', () => { + it('shows no source books when no target training books are selected', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + + expect(env.component.availableTrainingSourceBooksForProject('training-source-1-id')).toEqual([]); + }); + + it('limits available source books to those selected in the target', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + + env.component.onTargetTrainingBookSelect([Canon.bookIdToNumber('MAT'), Canon.bookIdToNumber('MRK')]); + + expect(env.availableTrainingSourceBookIds('training-source-1-id')).toEqual(['MAT', 'MRK']); + }); + + it('auto-selects source books when they are selected in the target', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + + env.component.onTargetTrainingBookSelect([Canon.bookIdToNumber('MAT'), Canon.bookIdToNumber('MRK')]); + + const available = env.component.availableTrainingSourceBooksForProject('training-source-1-id'); + expect(available.every(b => b.selected)).toBeTrue(); + }); + + it('pairs every book into the reference sources when many are selected at once (bulk select)', async () => { + // The OT/NT/DC "select all" controls (rich mode) emit the whole set of book numbers in one event; the pairing + // must handle a multi-book selection, not just one book at a time. + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + + const allTargetBookNumbers = env.component.availableTargetTrainingBooks.map(b => b.number); + expect(allTargetBookNumbers.length).toBeGreaterThan(1); + env.component.onTargetTrainingBookSelect(allTargetBookNumbers); + + // Every selected target book that exists in the source is now selected there too. + const expectedIds = allTargetBookNumbers.map(n => Canon.bookNumberToId(n)).sort(); + expect(env.selectedTrainingSourceBookIds('training-source-1-id').sort()).toEqual(expectedIds); + }); + + it('does not offer a target book that is not in any training source', async () => { + const stateWithoutGEN = { + ...testState, + trainingSourcesBooksChapters: { 'training-source-1-id': 'MAT1-28;MRK1-16;LUK1-24;JHN1-21' } + }; + const env = new TestEnvironment(stateWithoutGEN); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + + // GEN is in the target project but not in the training source, so it isn't offered as a target training book. + // Instead it's recorded so the UI can explain why it's missing. + expect(env.component.availableTargetTrainingBooks.map(book => book.number)).not.toContain( + Canon.bookIdToNumber('GEN') + ); + expect(env.component.logicHandler.targetTrainingBooksWithoutSource).toContain('GEN'); + }); + + it('removes a book from source when it is deselected in the target', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + env.component.onTargetTrainingBookSelect([Canon.bookIdToNumber('MAT'), Canon.bookIdToNumber('MRK')]); + + env.component.onTargetTrainingBookSelect([Canon.bookIdToNumber('MAT')]); + + expect(env.availableTrainingSourceBookIds('training-source-1-id')).not.toContain('MRK'); + expect(env.selectedTrainingSourceBookIds('training-source-1-id')).not.toContain('MRK'); + }); + + it('preserves a manual source deselection when other target books change', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + // Select MAT and MRK - both auto-selected in source + env.component.onTargetTrainingBookSelect([Canon.bookIdToNumber('MAT'), Canon.bookIdToNumber('MRK')]); + // User manually deselects MRK from the source + env.component.onTrainingSourceBookSelect([Canon.bookIdToNumber('MAT')], 'training-source-1-id'); + + // Add LUK to target - LUK should be auto-selected, MRK should stay deselected + env.component.onTargetTrainingBookSelect([ + Canon.bookIdToNumber('MAT'), + Canon.bookIdToNumber('MRK'), + Canon.bookIdToNumber('LUK') + ]); + + expect(env.selectedTrainingSourceBookIds('training-source-1-id')).not.toContain('MRK'); + expect(env.selectedTrainingSourceBookIds('training-source-1-id')).toContain('MAT'); + expect(env.selectedTrainingSourceBookIds('training-source-1-id')).toContain('LUK'); + }); + }); + + describe('training-pair forward gate', () => { + // Select MAT and MRK as target training books (auto-selected in the source), then manually deselect MRK from the + // source. MRK is now a selected training book with no matching reference selected. Position the wizard on the + // training step so next() exercises the forward gate. + async function setUpOrphanedTrainingBook(env: TestEnvironment): Promise { + await env.waitForInit(); + env.component.logicHandler.setInputMode('training_books'); + env.component.onTargetTrainingBookSelect([Canon.bookIdToNumber('MAT'), Canon.bookIdToNumber('MRK')]); + env.component.onTrainingSourceBookSelect([Canon.bookIdToNumber('MAT')], 'training-source-1-id'); + env.component.page = 'training_books'; + } + + it('blocks advancing when a selected training book has no matching reference book selected', async () => { + const env = new TestEnvironment(testState); + await setUpOrphanedTrainingBook(env); + + env.component.next(); + + expect(env.component.stepError).toBe('no_training_pair_selected'); + expect(env.component.page).toBe('training_books'); + }); + + it('clears the error and advances once a matching reference book is selected', async () => { + const env = new TestEnvironment(testState); + await setUpOrphanedTrainingBook(env); + env.component.next(); + expect(env.component.stepError).toBe('no_training_pair_selected'); + + // Re-select MRK in the source, pairing it again. + env.component.onTrainingSourceBookSelect( + [Canon.bookIdToNumber('MAT'), Canon.bookIdToNumber('MRK')], + 'training-source-1-id' + ); + env.component.next(); + + expect(env.component.stepError).toBeNull(); + expect(env.component.page).toBe('suffix'); + }); + + it('blocks an unpaired training book even when training is optional', async () => { + const env = new TestEnvironment(testState, { trainingOptional: true }); + await setUpOrphanedTrainingBook(env); + + env.component.next(); + + expect(env.component.stepError).toBe('no_training_pair_selected'); + expect(env.component.page).toBe('training_books'); + }); + }); + + describe('onDraftingBookSelect', () => { + it('removes stale errors for books no longer offered for partial drafting', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + env.component.onDraftingChaptersBlurred('GEN', 'abc'); + expect(env.component.draftingChapterErrors.has('GEN')).toBeTrue(); + + // Deselect GEN - switch to MAT only, removing GEN from booksOfferedForPartialDrafting + env.component.onDraftingBookSelect([Canon.bookIdToNumber('MAT')]); + + expect(env.component.draftingChapterErrors.has('GEN')).toBeFalse(); + }); + + it('keeps errors for books still offered for partial drafting', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + env.component.onDraftingChaptersBlurred('GEN', 'abc'); + + // Re-select GEN along with another book - GEN remains offered for partial drafting + env.component.onDraftingBookSelect([Canon.bookIdToNumber('GEN'), Canon.bookIdToNumber('MAT')]); + + expect(env.component.draftingChapterErrors.has('GEN')).toBeTrue(); + }); + }); + + describe('copyrightMessages', () => { + it('returns empty when no sources have copyright banners', async () => { + const env = new TestEnvironment(testState); + await env.waitForInit(); + + expect(env.component.copyrightMessages).toEqual([]); + }); + + it('returns messages from both drafting and training sources', async () => { + const env = new TestEnvironment({ + ...testState, + draftingSourceCopyrightBanner: 'Drafting source copyright', + trainingSourceCopyrightBanners: { 'training-source-1-id': 'Training source copyright' } + }); + await env.waitForInit(); + + const banners = env.component.copyrightMessages.map(m => m.banner); + expect(banners).toContain('Drafting source copyright'); + expect(banners).toContain('Training source copyright'); + }); + + it('deduplicates sources that share the same banner text', async () => { + const sharedBanner = 'Shared copyright notice'; + const env = new TestEnvironment({ + ...testState, + draftingSourceCopyrightBanner: sharedBanner, + trainingSourceCopyrightBanners: { 'training-source-1-id': sharedBanner } + }); + await env.waitForInit(); + + expect(env.component.copyrightMessages.length).toBe(1); + expect(env.component.copyrightMessages[0].banner).toBe(sharedBanner); + }); + }); + + describe('generateDraftClicked', () => { + beforeEach(() => { + reset(mockedDraftGenerationService); + reset(mockedRouter); + when(mockedDraftGenerationService.startBuildOrGetActiveBuild(anything())).thenReturn(of(undefined)); + }); + + it('sends chapter-level translation range for a partially drafted book', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); // runs logicHandler.init() and component.init() to completion (sets initData) + // GEN: source has chapters 1-50, target has 1-5 -> default draft selection is 6-50 + env.component.logicHandler.selectDraftingBooks(['GEN']); + + env.component.generateDraftClicked(); + tick(); + + verify( + mockedDraftGenerationService.startBuildOrGetActiveBuild( + deepEqual({ + projectId: 'testProjectId', + translationScriptureRanges: [{ projectId: 'draft-source-1-id', scriptureRange: 'GEN6-50' }], + trainingScriptureRanges: [{ projectId: 'testProjectId', scriptureRange: '' }], + trainingDataFiles: [], + availableTrainingDataFiles: [], + fastTraining: false, + useEcho: false, + sendEmailOnBuildFinished: false + }) + ) + ).once(); + expect().nothing(); + })); + + it('includes chapter-level target training range and book-level training source ranges', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + // Draft GEN 6-50; target training defaults to non-drafted chapters GEN 1-5 + env.component.logicHandler.selectDraftingBooks(['GEN']); + env.component.logicHandler.setInputMode('training_books'); + env.component.logicHandler.selectTargetTrainingBooks(['GEN']); + env.component.logicHandler.selectTrainingSourceBooks('training-source-1-id', ['GEN']); + + env.component.generateDraftClicked(); + tick(); + + verify( + mockedDraftGenerationService.startBuildOrGetActiveBuild( + deepEqual({ + projectId: 'testProjectId', + translationScriptureRanges: [{ projectId: 'draft-source-1-id', scriptureRange: 'GEN6-50' }], + trainingScriptureRanges: [ + { projectId: 'training-source-1-id', scriptureRange: 'GEN' }, + { projectId: 'testProjectId', scriptureRange: 'GEN1-5' } + ], + trainingDataFiles: [], + availableTrainingDataFiles: [], + fastTraining: false, + useEcho: false, + sendEmailOnBuildFinished: false + }) + ) + ).once(); + expect().nothing(); + })); + + it('navigates to draft-generation after submitting the build', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + env.component.generateDraftClicked(); + tick(); + + verify(mockedRouter.navigate(deepEqual(['/projects', 'testProjectId', 'draft-generation']))).once(); + expect().nothing(); + })); + + it('does not call the backend when offline', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + env.onlineStatusService.setIsOnline(false); + + env.component.generateDraftClicked(); + tick(); + + verify(mockedDraftGenerationService.startBuildOrGetActiveBuild(anything())).never(); + expect().nothing(); + })); + + it('sends the selected files and the full available set of training data files', fakeAsync(() => { + const env = new TestEnvironment({ + ...testState, + trainingDataFiles: [makeTrainingData('a'), makeTrainingData('b'), makeTrainingData('c')], + // 'a' was used last time; 'b' was offered but deselected; 'c' is newly added + lastSelectedTrainingDataFiles: ['a'], + lastAvailableTrainingDataFiles: ['a', 'b'] + }); + tick(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + + env.component.generateDraftClicked(); + tick(); + + const [config] = capture(mockedDraftGenerationService.startBuildOrGetActiveBuild).last(); + // 'a' (used last time) and 'c' (new) default selected; 'b' (deselected last time) stays off + expect(config.trainingDataFiles).toEqual(['a', 'c']); + // The full set offered is always reported so a later build can detect new vs deselected files + expect(config.availableTrainingDataFiles).toEqual(['a', 'b', 'c']); + })); + }); + + describe('training data file selection', () => { + it('defaults selection from the last build, keeping used and newly added files', fakeAsync(() => { + const env = new TestEnvironment({ + ...testState, + trainingDataFiles: [makeTrainingData('a'), makeTrainingData('b'), makeTrainingData('c')], + lastSelectedTrainingDataFiles: ['a'], + lastAvailableTrainingDataFiles: ['a', 'b'] + }); + tick(); + + expect(env.component.isTrainingDataFileSelected('a')).toBe(true); // used last time + expect(env.component.isTrainingDataFileSelected('b')).toBe(false); // deselected last time + expect(env.component.isTrainingDataFileSelected('c')).toBe(true); // newly added + })); + + it('selects all files for a legacy config with no recorded available set', fakeAsync(() => { + const env = new TestEnvironment({ + ...testState, + trainingDataFiles: [makeTrainingData('a'), makeTrainingData('b')], + lastSelectedTrainingDataFiles: [], + lastAvailableTrainingDataFiles: undefined + }); + tick(); + + expect(env.component.isTrainingDataFileSelected('a')).toBe(true); + expect(env.component.isTrainingDataFileSelected('b')).toBe(true); + })); + + it('exposes the titles of the selected files for the summary recap', fakeAsync(() => { + const env = new TestEnvironment({ + ...testState, + trainingDataFiles: [ + makeTrainingData('a', 'Alpha'), + makeTrainingData('b', 'Beta'), + makeTrainingData('c', 'Gamma') + ], + lastSelectedTrainingDataFiles: ['a'], + lastAvailableTrainingDataFiles: ['a', 'b'] + }); + tick(); + + // 'a' (used last time) and 'c' (new) default selected; 'b' (deselected last time) stays off + expect(env.component.selectedTrainingDataFileTitles).toEqual(['Alpha', 'Gamma']); + + env.component.onTrainingDataFileToggled('b', true); + expect(env.component.selectedTrainingDataFileTitles).toEqual(['Alpha', 'Beta', 'Gamma']); + })); + + it('toggles a file selection on and off', fakeAsync(() => { + const env = new TestEnvironment({ + ...testState, + trainingDataFiles: [makeTrainingData('a')], + lastSelectedTrainingDataFiles: ['a'], + lastAvailableTrainingDataFiles: ['a'] + }); + tick(); + + env.component.onTrainingDataFileToggled('a', false); + expect(env.component.isTrainingDataFileSelected('a')).toBe(false); + env.component.onTrainingDataFileToggled('a', true); + expect(env.component.isTrainingDataFileSelected('a')).toBe(true); + })); + + it('prunes the selection when a file is removed, without re-running the defaults', fakeAsync(() => { + const trainingData$ = new BehaviorSubject([makeTrainingData('a'), makeTrainingData('b')]); + const env = new TestEnvironment( + { ...testState, lastSelectedTrainingDataFiles: ['a', 'b'], lastAvailableTrainingDataFiles: ['a', 'b'] }, + { trainingData$ } + ); + tick(); + + expect(env.component.isTrainingDataFileSelected('a')).toBe(true); + expect(env.component.isTrainingDataFileSelected('b')).toBe(true); + + // 'b' is deleted from the project after the wizard has already loaded + trainingData$.next([makeTrainingData('a')]); + tick(); + + expect(env.component.trainingDataFiles.map(f => f.dataId)).toEqual(['a']); + expect(env.component.isTrainingDataFileSelected('a')).toBe(true); + // The removed file is dropped from the selection rather than lingering + expect(env.component.isTrainingDataFileSelected('b')).toBe(false); + })); + }); + + describe('detectPendingUpdates', () => { + // Reset call history so the per-test verify() counts aren't inflated by other tests' init flows. + beforeEach(() => { + reset(mockedParatextService); + reset(mockedErrorReportingService); + }); + + it('shows the pending-updates page for an involved, connected project with an update', fakeAsync(() => { + const env = new TestEnvironment(testState, { + projects: [makeParatextProject({ projectId: 'draft-source-1-id', name: 'Draft Source 1', hasUpdate: true })] + }); + tick(); + + expect(env.component.page).toEqual('pending_updates'); + expect(env.component.pendingProjects).toEqual([{ projectId: 'draft-source-1-id', name: 'Draft Source 1' }]); + })); + + it('excludes projects that are not involved, not connected, or have no update', fakeAsync(() => { + const env = new TestEnvironment(testState, { + projects: [ + makeParatextProject({ projectId: 'unrelated-id', hasUpdate: true }), // not involved + makeParatextProject({ projectId: 'draft-source-1-id', isConnected: false, hasUpdate: true }), // not connected + makeParatextProject({ projectId: 'training-source-1-id', hasUpdate: false }), // no update + makeParatextProject({ projectId: null, hasUpdate: true }) // no SF project id + ] + }); + tick(); + + expect(env.component.pendingProjects).toEqual([]); + expect(env.component.page).toEqual('preface'); + })); + + it('falls back to shortName when the project name is empty', fakeAsync(() => { + const env = new TestEnvironment(testState, { + projects: [makeParatextProject({ projectId: 'testProjectId', name: '', shortName: 'TGT', hasUpdate: true })] + }); + tick(); + + expect(env.component.pendingProjects).toEqual([{ projectId: 'testProjectId', name: 'TGT' }]); + })); + + it('skips detection entirely when offline', fakeAsync(() => { + const env = new TestEnvironment(testState, { + offline: true, + projects: [makeParatextProject({ projectId: 'draft-source-1-id', hasUpdate: true })] + }); + tick(); + + expect(env.component.pendingProjects).toEqual([]); + expect(env.component.page).toEqual('preface'); + verify(mockedParatextService.getProjects()).never(); + })); + + it('proceeds to the preface page when getProjects fails', fakeAsync(() => { + const env = new TestEnvironment(testState, { getProjectsError: true }); + tick(); + + expect(env.component.page).toEqual('preface'); + verify(mockedErrorReportingService.silentError(anything(), anything())).once(); + })); + }); + + describe('abort handling', () => { + // Reset call history so the per-test verify() counts aren't inflated by other tests' flows. + beforeEach(() => { + reset(mockedRouter); + reset(mockedErrorHandler); + }); + + it('shows the abort screen with no_access mode when a project is inaccessible', fakeAsync(() => { + const env = new TestEnvironment(testState, { noAccessSources: true }); + tick(); + + expect(env.component.page).toEqual('abort'); + expect(env.component.abortMode).toEqual('no_access'); + })); + + it('exposes the inaccessible project names for the abort screen', fakeAsync(() => { + const env = new TestEnvironment(testState, { noAccessSources: true }); + tick(); + + expect(env.component.inaccessibleProjectNames).toEqual(['IS1 - Inaccessible Source']); + })); + + it('navigates back to draft generation from the no_access abort screen', fakeAsync(() => { + const env = new TestEnvironment(testState, { noAccessSources: true }); + tick(); + + env.component.goBack(); + + verify(mockedRouter.navigate(deepEqual(['/projects', 'testProjectId', 'draft-generation']))).once(); + expect().nothing(); + })); + + it('delegates to the global error handler and navigates back when initialization fails', fakeAsync(() => { + const env = new TestEnvironment(testState, { progressError: true }); + tick(); + + // Fatal/unanticipated failures go to the app-wide error dialog rather than a bespoke abort screen. + verify(mockedErrorHandler.handleError(anything())).once(); + verify(mockedRouter.navigate(deepEqual(['/projects', 'testProjectId', 'draft-generation']))).once(); + expect(env.component.page).not.toEqual('abort'); + })); + }); + + describe('onPendingUpdatesComplete', () => { + beforeEach(() => { + reset(mockedRouter); + reset(mockedErrorHandler); + }); + + it('re-derives progress fresh for the synced projects, then shows the preface', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + resetCalls(mockedProgressService); // ignore the calls made during init + + void env.component.onPendingUpdatesComplete(['draft-source-1-id']); + tick(); + + // The reload re-fetches and forces fresh data (maxStalenessMs: 0) for the synced project. + verify(mockedProgressService.getProgressForProject('draft-source-1-id', deepEqual({ maxStalenessMs: 0 }))).once(); + expect(env.component.page).toEqual('preface'); + })); + + it('skips the reload and goes straight to the preface when nothing was synced', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + resetCalls(mockedProgressService); + + void env.component.onPendingUpdatesComplete([]); + tick(); + + verify(mockedProgressService.getProgressForProject(anything(), anything())).never(); + expect(env.component.page).toEqual('preface'); + })); + + it('routes a reload failure to the global error handler and navigates back', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + // Init has already succeeded; make the reload's progress fetch fail. + when(mockedProgressService.getProgressForProject('draft-source-1-id', anything())).thenReject( + new Error('reload failed') + ); + + void env.component.onPendingUpdatesComplete(['draft-source-1-id']); + tick(); + + verify(mockedErrorHandler.handleError(anything())).once(); + verify(mockedRouter.navigate(deepEqual(['/projects', 'testProjectId', 'draft-generation']))).once(); + expect().nothing(); + })); + }); + + describe('sync watcher', () => { + it('does not abort when a project starts syncing during the pending-updates pre-step', fakeAsync(() => { + const env = new TestEnvironment(testState, { + projects: [makeParatextProject({ projectId: 'draft-source-1-id', name: 'Draft Source 1', hasUpdate: true })] + }); + tick(); + expect(env.component.page).toEqual('pending_updates'); + + // The pre-step is designed to absorb syncs (via reload), so the watcher must not be armed yet. + env.startSyncFor('draft-source-1-id'); + tick(); + + expect(env.component.page).toEqual('pending_updates'); + })); + + it('aborts with project_syncing when an involved project starts syncing after lock-in', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + expect(env.component.page).toEqual('preface'); + + env.startSyncFor('draft-source-1-id'); + tick(); + + expect(env.component.page).toEqual('abort'); + expect(env.component.abortMode).toEqual('project_syncing'); + })); + + it('aborts when the target project starts syncing after lock-in', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + + env.startSyncFor('testProjectId'); + tick(); + + expect(env.component.page).toEqual('abort'); + expect(env.component.abortMode).toEqual('project_syncing'); + })); + + it('does not abort when a project was already syncing at lock-in', fakeAsync(() => { + // The user continued past a pre-existing (possibly stuck) sync; that in-flight sync is the baseline, not an edge. + const env = new TestEnvironment(testState, { targetSyncing: true }); + tick(); + + expect(env.component.page).toEqual('preface'); + + // A further change while it is still syncing is not a not-syncing→syncing edge, so it still must not abort. + env.startSyncFor('testProjectId'); + tick(); + + expect(env.component.page).toEqual('preface'); + })); + + it('arms the watcher after the user leaves the pending-updates pre-step', fakeAsync(() => { + const env = new TestEnvironment(testState, { + projects: [makeParatextProject({ projectId: 'draft-source-1-id', name: 'Draft Source 1', hasUpdate: true })] + }); + tick(); + expect(env.component.page).toEqual('pending_updates'); + + void env.component.onPendingUpdatesComplete([]); + tick(); + expect(env.component.page).toEqual('preface'); + + env.startSyncFor('draft-source-1-id'); + tick(); + + expect(env.component.page).toEqual('abort'); + expect(env.component.abortMode).toEqual('project_syncing'); + })); + }); + + describe('empty states', () => { + it('reports no available drafting books when the source offers none', async () => { + const env = new TestEnvironment({ + draftingSourceBooksChapters: '', + targetProjectBooksChapters: 'GEN1-5', + trainingSourcesBooksChapters: { 'training-source-1-id': 'GEN1-50' } + }); + await env.waitForInit(); + + // Drives the "no books available to draft" empty state on Step 2. + expect(env.component.availableDraftingBooks).toEqual([]); + }); + + it('reports no target training books when none are in any reference project', async () => { + const env = new TestEnvironment({ + draftingSourceBooksChapters: 'GEN1-50', + targetProjectBooksChapters: 'GEN1-5', + trainingSourcesBooksChapters: { 'training-source-1-id': 'EXO1-40' } + }); + await env.waitForInit(); + env.component.logicHandler.selectDraftingBooks(['GEN']); + env.component.logicHandler.setInputMode('training_books'); + + // GEN is the only target book and it isn't in the reference project, so it's withheld → empty state on Step 3. + expect(env.component.availableTargetTrainingBooks).toEqual([]); + }); + }); + + describe('hidden-books notices', () => { + it('counts only the surfaced drafting-exclusion reasons (not non-canonical)', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + env.component.logicHandler.excludedDraftingBooks = [ + { bookId: 'GEN', reason: 'no_source_content' }, + { bookId: 'EXO', reason: 'not_in_target' }, + { bookId: 'FRT', reason: 'non_canonical' } // tracked but never surfaced + ]; + + expect(env.component.draftingHiddenBookCount).toBe(2); + expect(env.component.draftingExclusionNotices.length).toBe(2); + })); + + it('counts target training books hidden for lacking a matching source', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + env.component.logicHandler.targetTrainingBooksWithoutSource = ['LEV', 'NUM']; + + expect(env.component.targetTrainingHiddenBookCount).toBe(2); + expect(env.component.hasTargetTrainingBooksWithoutSource).toBeTrue(); + })); + + it('starts with the explanations collapsed on both steps', fakeAsync(() => { + const env = new TestEnvironment(testState); + tick(); + + expect(env.component.draftingExclusionsExpanded).toBeFalse(); + expect(env.component.trainingExclusionsExpanded).toBeFalse(); + })); + }); +}); + +const mockedActivatedProjectService = mock(ActivatedProjectService); +const mockedDraftSourcesService = mock(DraftSourcesService); +const mockedDraftGenerationService = mock(DraftGenerationService); +const mockedProgressService = mock(DraftProgressService); +const mockedI18nService = mock(I18nService); +const mockedFeatureFlagService = mock(FeatureFlagService); +const mockedUserService = mock(UserService); +const mockedRouter = mock(Router); +const mockedNllbLanguageService = mock(NllbLanguageService); +const mockedParatextService = mock(ParatextService); +const mockedErrorReportingService = mock(ErrorReportingService); +const mockedErrorHandler = mock(ErrorHandler); +const mockedTrainingDataService = mock(TrainingDataService); +const mockedSFProjectService = mock(SFProjectService); + +interface TestState { + draftingSourceBooksChapters: string; + targetProjectBooksChapters: string; + trainingSourcesBooksChapters: { [key: string]: string }; + draftingSourceCopyrightBanner?: string; + trainingSourceCopyrightBanners?: { [key: string]: string }; + /** Training data files currently available for the project. */ + trainingDataFiles?: TrainingData[]; + lastSelectedTrainingDataFiles?: string[]; + lastAvailableTrainingDataFiles?: string[]; + /** Target books getCompleteBookIds should report as complete (auto-selectable on first draft). Defaults to none. */ + completeTargetBooks?: string[]; +} + +function makeTrainingData(dataId: string, title: string = dataId): TrainingData { + return { + dataId, + title, + projectRef: 'testProjectId', + ownerRef: 'user01', + fileUrl: `https://example.com/${dataId}.csv`, + mimeType: 'text/csv', + skipRows: 0 + }; +} + +// `hasUpdate` is required so each call states explicitly whether the project has a pending update. +function makeParatextProject( + overrides: Partial & Pick +): ParatextProject { + return { + paratextId: 'pt-id', + name: 'A Project', + shortName: 'PRJ', + languageTag: 'en', + projectId: 'sf-project-id', + isConnectable: true, + isConnected: true, + hasUserRoleChanged: false, + role: SFProjectRole.ParatextAdministrator, + ...overrides + }; +} + +class TestEnvironment { + component: NewDraftComponent; + readonly onlineStatusService = TestBed.inject(TestOnlineStatusService); + + /** Mutable sync-state profile docs (target + sources) the sync watcher subscribes to, keyed by project id. */ + private readonly syncDocs = new Map }>(); + + private makeSyncDoc(projectId: string, data: { sync: { queuedCount: number } }): SFProjectProfileDoc { + const changes$ = new Subject(); + this.syncDocs.set(projectId, { data, changes$ }); + return { data, remoteChanges$: changes$ } as unknown as SFProjectProfileDoc; + } + + /** Simulates a project starting to sync (queuedCount 0 → 1) and notifying watchers. */ + startSyncFor(projectId: string): void { + const entry = this.syncDocs.get(projectId); + if (entry == null) throw new Error(`No sync doc registered for ${projectId}`); + entry.data.sync.queuedCount = 1; + entry.changes$.next(); + } + + constructor( + state: TestState, + options: { + getProjectsError?: boolean; + projects?: ParatextProject[]; + offline?: boolean; + noAccessSources?: boolean; + progressError?: boolean; + /** When true, both languages are treated as NLLB languages so that training is optional. */ + trainingOptional?: boolean; + /** Overrides the training-data stream so a test can emit changes over time (e.g. a file being removed). */ + trainingData$?: Observable; + /** When true, the target project is already syncing on entry (baseline for the sync watcher). */ + targetSyncing?: boolean; + } = {} + ) { + const project = createTestProjectProfile({ + shortName: TARGET_SHORT_NAME, + translateConfig: { + preTranslate: true, + draftConfig: { + draftingSources: [ + { + paratextId: 'draft-source-1-pt-id', + projectRef: 'draft-source-1-id', + name: 'Draft Source 1', + shortName: SOURCE_SHORT_NAME, + writingSystem: { script: 'Latn', tag: 'es' } + } + ], + trainingSources: Object.keys(state.trainingSourcesBooksChapters).map(projectId => ({ + paratextId: `${projectId}-pt-id`, + projectRef: projectId, + name: `Training Source for ${projectId}`, + shortName: `TS-${projectId}`, + writingSystem: { script: 'Latn', tag: 'es' } + })), + lastSelectedTrainingScriptureRanges: [], + lastSelectedTrainingDataFiles: state.lastSelectedTrainingDataFiles ?? [], + lastAvailableTrainingDataFiles: state.lastAvailableTrainingDataFiles, + lastSelectedTranslationScriptureRanges: undefined + } + } + }); + + if (options.targetSyncing) project.sync.queuedCount = 1; + + const projectId = 'testProjectId'; + // The target doc doubles as a sync-watcher doc so tests can simulate the target starting to sync. + const targetDoc = this.makeSyncDoc(projectId, project as unknown as { sync: { queuedCount: number } }); + when(mockedActivatedProjectService.projectId).thenReturn(projectId); + when(mockedActivatedProjectService.projectId$).thenReturn(of(projectId)); + when(mockedActivatedProjectService.projectDoc).thenReturn(targetDoc); + when(mockedActivatedProjectService.projectDoc$).thenReturn(of(targetDoc)); + + // Sync-watcher profile docs for the source projects (start not-syncing). + for (const sourceId of ['draft-source-1-id', ...Object.keys(state.trainingSourcesBooksChapters)]) { + when(mockedSFProjectService.getProfile(sourceId)).thenResolve( + this.makeSyncDoc(sourceId, { sync: { queuedCount: 0 } }) + ); + } + + if (options.noAccessSources) { + when(mockedDraftSourcesService.getDraftProjectSources()).thenReturn( + of({ + trainingSources: [ + { noAccess: true, shortName: 'IS1', name: 'Inaccessible Source' } as unknown as DraftSource + ], + trainingTargets: [], + draftingSources: [] + }) + ); + } else { + when(mockedDraftSourcesService.getDraftProjectSources()).thenReturn( + of({ + trainingSources: Object.keys(state.trainingSourcesBooksChapters).map(projectId => ({ + paratextId: `${projectId}-pt-id`, + projectRef: projectId, + name: `Training Source for ${projectId}`, + shortName: `TS-${projectId}`, + writingSystem: { script: 'Latn', tag: 'es' }, + texts: [], + copyrightBanner: state.trainingSourceCopyrightBanners?.[projectId] + })) as DraftSource[], + trainingTargets: [], + draftingSources: [ + { + paratextId: 'draft-source-1-pt-id', + projectRef: 'draft-source-1-id', + name: 'Draft Source 1', + shortName: SOURCE_SHORT_NAME, + writingSystem: { script: 'Latn', tag: 'es' }, + texts: [], + copyrightBanner: state.draftingSourceCopyrightBanner + } as DraftSource + ] + }) + ); + } + + if (options.progressError) { + when(mockedProgressService.getProgressForProject(projectId, anything())).thenReject(new Error('progress failed')); + } else { + when(mockedProgressService.getProgressForProject(projectId, anything())).thenResolve( + new VerboseScriptureRange(state.targetProjectBooksChapters) + ); + } + when(mockedProgressService.getProgressForProject('draft-source-1-id', anything())).thenResolve( + new VerboseScriptureRange(state.draftingSourceBooksChapters) + ); + for (const [trainingSourceId, booksChapters] of Object.entries(state.trainingSourcesBooksChapters)) { + when(mockedProgressService.getProgressForProject(trainingSourceId, anything())).thenResolve( + new VerboseScriptureRange(booksChapters) + ); + } + when(mockedProgressService.getCompleteBookIds(projectId, anything())).thenResolve( + new Set(state.completeTargetBooks ?? []) + ); + + when(mockedTrainingDataService.getTrainingData(anything(), anything())).thenReturn( + options.trainingData$ ?? of(state.trainingDataFiles ?? []) + ); + + when(mockedFeatureFlagService.showDeveloperTools).thenReturn(createTestFeatureFlag(false)); + when(mockedUserService.getCurrentUser()).thenResolve(undefined as any); + when(mockedNllbLanguageService.isNllbLanguageAsync(anything())).thenResolve(options.trainingOptional === true); + // Set the online state before the component is constructed so init()'s online check sees it. + this.onlineStatusService.setIsOnline(!options.offline); + if (options.getProjectsError) { + when(mockedParatextService.getProjects()).thenReject(new Error('network error')); + } else { + when(mockedParatextService.getProjects()).thenResolve(options.projects); + } + + this.component = new NewDraftComponent( + instance(mockedActivatedProjectService), + instance(mockedDraftSourcesService), + instance(mockedDraftGenerationService), + instance(mockedProgressService), + instance(mockedI18nService), + instance(mockedFeatureFlagService), + this.onlineStatusService, + instance(mockedUserService), + instance(mockedRouter), + instance(mockedNllbLanguageService), + instance(mockedParatextService), + instance(mockedErrorReportingService), + instance(mockedErrorHandler), + instance(mockedTrainingDataService), + instance(mockedSFProjectService), + { onDestroy: () => () => {} } as unknown as DestroyRef + ); + } + + async waitForInit(): Promise { + await firstValueFrom(this.component.logicHandler.status$.pipe(filter(s => s === 'input'))); + } + + /** Selects GEN for drafting (GEN6-50) and GEN for training (GEN1-5 available). */ + async selectGENForTraining(): Promise { + this.component.logicHandler.selectDraftingBooks(['GEN']); + this.component.logicHandler.setInputMode('training_books'); + this.component.logicHandler.selectTargetTrainingBooks(['GEN']); + } + + availableTrainingSourceBookIds(projectId: string): string[] { + return this.component.availableTrainingSourceBooksForProject(projectId).map(b => Canon.bookNumberToId(b.number)); + } + + selectedTrainingSourceBookIds(projectId: string): string[] { + return this.component.selectedTrainingSourceBooksForProject(projectId).map(b => Canon.bookNumberToId(b.number)); + } + + get selectedDraftingScriptureRange(): string { + return this.component.logicHandler.selectedDraftingScriptureRange.toString(); + } + + get selectedTargetTrainingScriptureRange(): string { + return this.component.logicHandler.selectedTargetTrainingScriptureRange.toString(); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts new file mode 100644 index 00000000000..66e8929c497 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.stories.ts @@ -0,0 +1,505 @@ +import { ErrorHandler } from '@angular/core'; +import { Router } from '@angular/router'; +import { Canon } from '@sillsdev/scripture'; +import { Meta, StoryObj } from '@storybook/angular'; +import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project'; +import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; +import { TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data'; +import { of } from 'rxjs'; +import userEvent from '@testing-library/user-event'; +import { expect, waitFor, within } from 'storybook/test'; +import { anything, instance, mock, reset, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; +import { DialogService } from 'xforge-common/dialog.service'; +import { createTestFeatureFlag, FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { UserDoc } from 'xforge-common/models/user-doc'; +import { UserService } from 'xforge-common/user.service'; +import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { ParatextProject } from '../../../core/models/paratext-project'; +import { SFProjectDoc } from '../../../core/models/sf-project-doc'; +import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; +import { ParatextService } from '../../../core/paratext.service'; +import { PermissionsService } from '../../../core/permissions.service'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { ProgressService } from '../../../shared/progress-service/progress.service'; +import { NllbLanguageService } from '../../nllb-language.service'; +import { DraftGenerationService } from '../draft-generation.service'; +import { DraftSource, DraftSourcesAsArrays } from '../draft-source'; +import { DraftSourcesService } from '../draft-sources.service'; +import { TrainingDataService } from '../training-data/training-data.service'; +import { DraftProgressService } from './new-draft-logic-handler'; +import { NewDraftComponent } from './new-draft.component'; +import { VerboseScriptureRange } from '../../../shared/scripture-range'; + +// Project IDs used throughout the stories. +const TARGET_ID = 'target-project-id'; +const DRAFT_SOURCE_ID = 'draft-source-1-id'; +const TRAINING_SOURCE_ID = 'training-source-1-id'; + +// Books available in each project. GEN is the interesting case: the source has all 50 chapters but the target only +// has GEN1-5, so GEN6-50 is offered for drafting and GEN1-5 remains available for training (partial-book drafting). +// PHM is in both the source and the target (1 chapter), so selecting it produces a clean whole-book selection with no +// chapter table. JUD exists only in the source, so it is not offered for drafting and instead demonstrates the +// "not in the project" exclusion notice (see the ExcludedBooks story). +const TARGET_BOOKS = 'GEN1-5;MAT1-28;MRK1-16;LUK1-24;JHN1-21;PHM1'; +const DRAFT_SOURCE_BOOKS = 'GEN1-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21;PHM1;JUD1'; +const TRAINING_SOURCE_BOOKS = 'GEN1-50;MAT1-28;MRK1-16;LUK1-24;JHN1-21;PHM1'; + +const mockedActivatedProjectService = mock(ActivatedProjectService); +const mockedDraftSourcesService = mock(DraftSourcesService); +const mockedDraftGenerationService = mock(DraftGenerationService); +const mockedDraftProgressService = mock(DraftProgressService); +const mockedFeatureFlagService = mock(FeatureFlagService); +const mockedOnlineStatusService = mock(OnlineStatusService); +const mockedUserService = mock(UserService); +const mockedRouter = mock(Router); +const mockedNllbLanguageService = mock(NllbLanguageService); +const mockedParatextService = mock(ParatextService); +const mockedErrorReportingService = mock(ErrorReportingService); +const mockedErrorHandler = mock(ErrorHandler); +const mockedTrainingDataService = mock(TrainingDataService); +// Dependencies of the child components rendered inside the wizard. +const mockedProgressService = mock(ProgressService); +const mockedSFProjectService = mock(SFProjectService); +const mockedPermissionsService = mock(PermissionsService); +const mockedDialogService = mock(DialogService); +const mockedAuthService = mock(AuthService); + +interface StoryArgs { + /** Application online state. Offline disables Generate and skips the pending-updates pre-step. */ + online: boolean; + /** Enables the developer-tools feature flag (adds the Fast Training / Echo cards on the summary). */ + developerTools: boolean; + /** Gives the sources copyright banners, shown on the preface. */ + withCopyright: boolean; + /** Provides a couple of training-data files for the training step. */ + withTrainingDataFiles: boolean; + /** An administrator has applied a custom Serval config, shown as a notice on the summary. */ + withCustomConfig: boolean; + /** A source/target project is inaccessible, so the wizard aborts with the no-access screen. */ + noAccess: boolean; + /** An involved project has un-synced Paratext changes, so the sync interstitial is shown first. */ + hasPendingUpdates: boolean; + /** Never resolves project/progress loading, leaving the wizard on its loading spinner. */ + neverLoad: boolean; +} + +const defaultArgs: StoryArgs = { + online: true, + developerTools: false, + withCopyright: false, + withTrainingDataFiles: false, + withCustomConfig: false, + noAccess: false, + hasPendingUpdates: false, + neverLoad: false +}; + +function makeTrainingData(dataId: string, title: string): TrainingData { + return { + dataId, + title, + projectRef: TARGET_ID, + ownerRef: 'user01', + fileUrl: `https://example.com/${dataId}.csv`, + mimeType: 'text/csv', + skipRows: 0 + }; +} + +function buildProjectProfile(args: StoryArgs): SFProjectProfile { + return createTestProjectProfile({ + name: 'NTV - Nueva Traducción Viviente', + shortName: 'TP1', + writingSystem: { tag: 'en' }, + // The target's text list (book membership) gates which source books are offered for drafting. Include the books + // the target contains; the drafting source's extra book (JUD) is intentionally absent so the story shows the + // "not in the project" notice. + texts: Array.from(new VerboseScriptureRange(TARGET_BOOKS).books.keys()).map(bookId => ({ + bookNum: Canon.bookIdToNumber(bookId), + hasSource: false, + chapters: [], + permissions: {} + })), + translateConfig: { + preTranslate: true, + draftConfig: { + draftingSources: [ + { + paratextId: 'draft-source-1-pt-id', + projectRef: DRAFT_SOURCE_ID, + name: 'Spanish Draft Source', + shortName: 'DS1', + writingSystem: { script: 'Latn', tag: 'es' } + } + ], + trainingSources: [ + { + paratextId: 'training-source-1-pt-id', + projectRef: TRAINING_SOURCE_ID, + name: 'Reference Project', + shortName: 'RP1', + writingSystem: { script: 'Latn', tag: 'es' } + } + ], + // Restoring GEN for both the target and the reference source means entering the training step lands with a + // representative selection already made, so the summary and training steps show real data. + lastSelectedTrainingScriptureRanges: [ + { projectId: TARGET_ID, scriptureRange: 'GEN' }, + { projectId: TRAINING_SOURCE_ID, scriptureRange: 'GEN' } + ], + lastSelectedTrainingDataFiles: args.withTrainingDataFiles ? ['glossary'] : [], + lastAvailableTrainingDataFiles: args.withTrainingDataFiles ? ['glossary'] : [], + servalConfig: args.withCustomConfig ? '{ "custom": "value" }' : undefined, + sendEmailOnBuildFinished: false, + fastTraining: false, + useEcho: false + } + } + }); +} + +function draftSources(args: StoryArgs): DraftSourcesAsArrays { + if (args.noAccess) { + return { + trainingSources: [ + { noAccess: true, shortName: 'RP1', name: 'Reference Project' } as unknown as DraftSource, + { noAccess: true, shortName: 'DS1', name: 'Spanish Draft Source' } as unknown as DraftSource + ], + trainingTargets: [], + draftingSources: [] + }; + } + return { + trainingSources: [ + { + paratextId: 'training-source-1-pt-id', + projectRef: TRAINING_SOURCE_ID, + name: 'Reference Project', + shortName: 'RP1', + writingSystem: { script: 'Latn', tag: 'es' }, + texts: [], + copyrightBanner: args.withCopyright ? 'Reference text © Example Bible Society' : undefined + } as DraftSource + ], + trainingTargets: [], + draftingSources: [ + { + paratextId: 'draft-source-1-pt-id', + projectRef: DRAFT_SOURCE_ID, + name: 'Spanish Draft Source', + shortName: 'DS1', + writingSystem: { script: 'Latn', tag: 'es' }, + texts: [], + copyrightBanner: args.withCopyright ? 'Source text © Example Bible Society' : undefined + } as DraftSource + ] + }; +} + +function makeParatextProject(overrides: Partial): ParatextProject { + return { + paratextId: 'pt-id', + name: 'A Project', + shortName: 'PRJ', + languageTag: 'en', + projectId: 'sf-project-id', + isConnectable: true, + isConnected: true, + hasUpdate: false, + hasUserRoleChanged: false, + role: 'pt_administrator', + ...overrides + } as ParatextProject; +} + +function setUpMocks(args: StoryArgs): void { + for (const m of [ + mockedActivatedProjectService, + mockedDraftSourcesService, + mockedDraftGenerationService, + mockedDraftProgressService, + mockedFeatureFlagService, + mockedOnlineStatusService, + mockedUserService, + mockedRouter, + mockedNllbLanguageService, + mockedParatextService, + mockedErrorReportingService, + mockedErrorHandler, + mockedTrainingDataService, + mockedProgressService, + mockedSFProjectService, + mockedPermissionsService, + mockedDialogService, + mockedAuthService + ]) { + reset(m); + } + + const project = buildProjectProfile(args); + const projectDoc = { id: TARGET_ID, data: project } as SFProjectProfileDoc; + + when(mockedActivatedProjectService.projectId).thenReturn(TARGET_ID); + when(mockedActivatedProjectService.projectId$).thenReturn(of(TARGET_ID)); + when(mockedActivatedProjectService.projectDoc).thenReturn(projectDoc); + when(mockedActivatedProjectService.projectDoc$).thenReturn(of(projectDoc)); + when(mockedActivatedProjectService.changes$).thenReturn(of(projectDoc)); + + when(mockedDraftSourcesService.getDraftProjectSources()).thenReturn(of(draftSources(args))); + + // The logic handler loads progress for the target, the drafting source and the training source. When `neverLoad` is + // set the progress promises never resolve, leaving the wizard stuck on its loading spinner. + const progressFor = (range: string): Promise => + args.neverLoad ? new Promise(() => {}) : Promise.resolve(new VerboseScriptureRange(range)); + when(mockedDraftProgressService.getProgressForProject(TARGET_ID, anything())).thenCall(() => + progressFor(TARGET_BOOKS) + ); + when(mockedDraftProgressService.getProgressForProject(DRAFT_SOURCE_ID, anything())).thenCall(() => + progressFor(DRAFT_SOURCE_BOOKS) + ); + when(mockedDraftProgressService.getProgressForProject(TRAINING_SOURCE_ID, anything())).thenCall(() => + progressFor(TRAINING_SOURCE_BOOKS) + ); + when(mockedDraftProgressService.getCompleteBookIds(TARGET_ID, anything())).thenResolve(new Set()); + + when(mockedFeatureFlagService.showDeveloperTools).thenReturn(createTestFeatureFlag(args.developerTools)); + + when(mockedOnlineStatusService.isOnline).thenReturn(args.online); + when(mockedOnlineStatusService.onlineStatus$).thenReturn(of(args.online)); + + when(mockedUserService.getCurrentUser()).thenResolve({ + data: { email: 'translator@example.com' } + } as UserDoc); + + when(mockedNllbLanguageService.isNllbLanguageAsync(anything())).thenResolve(false); + + const pendingProjects = args.hasPendingUpdates + ? [makeParatextProject({ projectId: DRAFT_SOURCE_ID, name: 'Spanish Draft Source', hasUpdate: true })] + : []; + when(mockedParatextService.getProjects()).thenResolve(pendingProjects); + + const files = args.withTrainingDataFiles + ? [makeTrainingData('glossary', 'Glossary terms'), makeTrainingData('back-translation', 'Back translation notes')] + : []; + when(mockedTrainingDataService.getTrainingData(anything(), anything())).thenReturn(of(files)); + + when(mockedDraftGenerationService.startBuildOrGetActiveBuild(anything())).thenReturn(of(undefined)); + + // For the pending-updates interstitial: each involved project doc loads with no active sync, so the row is shown as + // "syncable" with a Sync button (no live SyncProgressComponent). + when(mockedSFProjectService.get(anything())).thenResolve({ + id: DRAFT_SOURCE_ID, + data: createTestProjectProfile({ shortName: 'DS1', sync: { queuedCount: 0, lastSyncSuccessful: true } }), + remoteChanges$: of() + } as unknown as SFProjectDoc); + when(mockedPermissionsService.canSync(anything())).thenReturn(true); +} + +const meta: Meta = { + title: 'Translate/NewDraft', + component: NewDraftComponent, + args: defaultArgs, + parameters: { controls: { include: Object.keys(defaultArgs) } }, + render: args => { + setUpMocks(args); + return { + props: args, + moduleMetadata: { + imports: [NewDraftComponent], + providers: [ + { provide: ActivatedProjectService, useValue: instance(mockedActivatedProjectService) }, + { provide: DraftSourcesService, useValue: instance(mockedDraftSourcesService) }, + { provide: DraftGenerationService, useValue: instance(mockedDraftGenerationService) }, + { provide: DraftProgressService, useValue: instance(mockedDraftProgressService) }, + { provide: FeatureFlagService, useValue: instance(mockedFeatureFlagService) }, + { provide: OnlineStatusService, useValue: instance(mockedOnlineStatusService) }, + { provide: UserService, useValue: instance(mockedUserService) }, + { provide: Router, useValue: instance(mockedRouter) }, + { provide: NllbLanguageService, useValue: instance(mockedNllbLanguageService) }, + { provide: ParatextService, useValue: instance(mockedParatextService) }, + { provide: ErrorReportingService, useValue: instance(mockedErrorReportingService) }, + { provide: ErrorHandler, useValue: instance(mockedErrorHandler) }, + { provide: TrainingDataService, useValue: instance(mockedTrainingDataService) }, + { provide: ProgressService, useValue: instance(mockedProgressService) }, + { provide: SFProjectService, useValue: instance(mockedSFProjectService) }, + { provide: PermissionsService, useValue: instance(mockedPermissionsService) }, + { provide: DialogService, useValue: instance(mockedDialogService) }, + { provide: AuthService, useValue: instance(mockedAuthService) } + ] + } + }; + } +}; + +export default meta; + +type Story = StoryObj; + +// --- Navigation helpers for the play functions --- + +type Canvas = ReturnType; + +/** Advance to the next wizard step. */ +async function clickNext(canvas: Canvas): Promise { + await userEvent.click(await canvas.findByRole('button', { name: /next/i })); +} + +/** Toggle a book chip (e.g. "Genesis") in the visible book multi-select. */ +async function toggleBook(canvas: Canvas, bookName: string): Promise { + await userEvent.click(await canvas.findByText(bookName)); +} + +/** preface -> draft_books, selecting GEN (the first chip) so the step's validation passes. Selecting by position + * rather than by name keeps this working under any locale (book names are localized; the chrome is not). */ +async function gotoDraftBooksWithGen(canvas: Canvas): Promise { + await clickNext(canvas); // preface -> draft_books + await canvas.findByText('Select books to draft'); + const books = await canvas.findAllByRole('option'); // ordered by book number, so GEN is first + await userEvent.click(books[0]); +} + +// --- Stories --- + +/** The wizard before its project and progress data have loaded. */ +export const Loading: Story = { + args: { neverLoad: true }, + play: async ({ canvasElement }) => { + await waitFor(() => expect(canvasElement.querySelector('.loading-indicator')).not.toBeNull()); + } +}; + +/** A source or reference project is inaccessible, so drafting cannot proceed. */ +export const Abort: Story = { + args: { noAccess: true }, + play: async ({ canvasElement }) => { + await waitFor(() => expect(canvasElement.querySelector('.abort-screen')).not.toBeNull()); + } +}; + +/** Pre-step interstitial: an involved project has un-synced Paratext changes. */ +export const PendingUpdates: Story = { + args: { hasPendingUpdates: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Spanish Draft Source'); + // The continue-anyway action is always offered; the wizard's own step buttons are hidden here. + expect(canvas.queryByRole('button', { name: /next/i })).toBeNull(); + } +}; + +/** First wizard step: confirm the configured sources before drafting. */ +export const Preface: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => expect(canvasElement.querySelector('.configure-sources-link')).not.toBeNull()); + await canvas.findByRole('button', { name: /next/i }); + } +}; + +/** Preface with copyright banners contributed by the sources. */ +export const PrefaceWithCopyright: Story = { + args: { withCopyright: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('Source text © Example Bible Society'); + await canvas.findByText('Reference text © Example Bible Society'); + } +}; + +/** Book selection step with a whole-book selection (PHM is in both projects but has no partial-chapter table). */ +export const SelectBooksToDraft: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await clickNext(canvas); // preface -> draft_books + await canvas.findByText('Select books to draft'); + await toggleBook(canvas, 'Philemon'); + // PHM has only one chapter, so it isn't eligible for partial drafting and no chapter inputs appear. + await waitFor(() => expect(canvasElement.querySelector('.partial-book-drafting-table')).toBeNull()); + } +}; + +/** A source-only book (JUD) isn't offered for drafting; expanding the collapsed notice explains why it's hidden. */ +export const ExcludedBooks: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await clickNext(canvas); // preface -> draft_books + await canvas.findByText('Select books to draft'); + // The exclusion notice starts collapsed; expand it to reveal which books were left out and why. + await userEvent.click(await canvas.findByText(/books are hidden/i)); + // JUD is in the source but not the target, so it is named in the "not in the project" notice. + await canvas.findByText(/Jude/); + } +}; + +/** Book selection with partial-book drafting: GEN exposes a chapter input, and an out-of-range value shows an error. */ +export const SelectChaptersToDraft: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await gotoDraftBooksWithGen(canvas); + // GEN is eligible for partial drafting, so a chapter input appears. + const input = await canvas.findByRole('textbox'); + await userEvent.clear(input); + await userEvent.type(input, '51-60'); // GEN only has 50 chapters in the source + await userEvent.tab(); // blur triggers validation + await waitFor(() => expect(canvasElement.querySelector('.chapter-error')).not.toBeNull()); + } +}; + +/** Training step: choose which of your own books, reference books, and training files to train on. */ +export const SelectTrainingBooks: Story = { + args: { withTrainingDataFiles: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await gotoDraftBooksWithGen(canvas); + await clickNext(canvas); // draft_books -> training_books + await canvas.findByText('Select books to train on'); + // The reference source and the training-data files both render. + await canvas.findByText('RP1'); + await canvas.findByText('Glossary terms'); + } +}; + +/** Final summary step, ready to generate the draft. A custom Serval config is set, so the summary also shows the + * "custom draft configurations have been applied" notice. */ +export const Summary: Story = { + args: { withCustomConfig: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await gotoDraftBooksWithGen(canvas); + await clickNext(canvas); // draft_books -> training_books + await canvas.findByText('Select books to train on'); + await clickNext(canvas); // training_books -> suffix + await canvas.findByRole('button', { name: /generate draft/i }); + await canvas.findByText('Custom draft configurations have been applied by an administrator.'); + } +}; + +/** Summary while offline and with developer tools enabled: Generate is disabled and the dev cards are shown. */ +export const SummaryOfflineDevTools: Story = { + args: { online: false, developerTools: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await gotoDraftBooksWithGen(canvas); + await clickNext(canvas); // draft_books -> training_books + await canvas.findByText('Select books to train on'); + await clickNext(canvas); // training_books -> suffix + await canvas.findByText('Developer options'); + const generate = await canvas.findByRole('button', { name: /generate draft/i }); + expect(generate).toBeDisabled(); + } +}; + +/** Right-to-left layout of the training step. */ +export const RTL: Story = { + args: { withTrainingDataFiles: true }, + parameters: { locale: 'ar' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await gotoDraftBooksWithGen(canvas); + await clickNext(canvas); // draft_books -> training_books + await canvas.findByText('RP1'); + } +}; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts new file mode 100644 index 00000000000..5aeb8d16ecd --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts @@ -0,0 +1,916 @@ +import { CommonModule } from '@angular/common'; +import { Component, DestroyRef, ErrorHandler } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { Router } from '@angular/router'; +import { TranslocoModule } from '@ngneat/transloco'; +import { Canon } from '@sillsdev/scripture'; +import { TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data'; +import { ProjectScriptureRange } from 'realtime-server/lib/esm/scriptureforge/models/translate-config'; +import { filter, firstValueFrom, take } from 'rxjs'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service'; +import { I18nKeyForComponent, I18nService } from 'xforge-common/i18n.service'; +import { UserDoc } from 'xforge-common/models/user-doc'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { UserService } from 'xforge-common/user.service'; +import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; +import { hasStringProp } from '../../../../type-utils'; +import { ParatextProject } from '../../../core/models/paratext-project'; +import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; +import { ParatextService } from '../../../core/paratext.service'; +import { SFProjectService } from '../../../core/sf-project.service'; +import { Book } from '../../../shared/book-multi-select/book-multi-select'; +import { BookMultiSelectComponent } from '../../../shared/book-multi-select/book-multi-select.component'; +import { CopyrightBannerComponent } from '../../../shared/copyright-banner/copyright-banner.component'; +import { NoticeComponent } from '../../../shared/notice/notice.component'; +import { ChapterSet, VerboseScriptureRange } from '../../../shared/scripture-range'; +import { projectLabel } from '../../../shared/utils'; +import { isSFProjectSyncing } from '../../../sync/sync.component'; +import { NllbLanguageService } from '../../nllb-language.service'; +import { ConfirmSourcesComponent } from '../confirm-sources/confirm-sources.component'; +import { BuildConfig } from '../draft-generation'; +import { DraftGenerationService } from '../draft-generation.service'; +import { CopyrightMessage, DraftSource, getCopyrightMessages } from '../draft-source'; +import { DraftSourcesService } from '../draft-sources.service'; +import { TrainingDataService } from '../training-data/training-data.service'; +import { DraftPendingUpdatesComponent } from './draft-pending-updates/draft-pending-updates.component'; +import { + DraftingBookExclusionReason, + DraftProgressService, + NewDraftAbortMode, + NewDraftLogicHandler, + scriptureRangeToBookListWithoutChapterDetail +} from './new-draft-logic-handler'; +import { defaultSelectedTrainingDataFiles } from './training-data-file-selection'; +import { formatTrainingBooksSummary } from './training-data-summary'; + +interface ChapterInputError { + key: I18nKeyForComponent<'new_draft'>; + params?: object; +} + +const PAGES_BY_ORDER = [ + { page: 'preface' }, + { page: 'draft_books', inputState: 'draft_books' }, + { page: 'training_books', inputState: 'training_books' }, + { page: 'suffix' } +] as const; + +@Component({ + selector: 'app-new-draft', + templateUrl: './new-draft.component.html', + styleUrls: ['./new-draft.component.scss'], + imports: [ + MatProgressSpinner, + ConfirmSourcesComponent, + CopyrightBannerComponent, + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatIconModule, + BookMultiSelectComponent, + MatFormFieldModule, + MatInputModule, + NoticeComponent, + DraftPendingUpdatesComponent, + FormsModule, + TranslocoModule, + CommonModule + ] +}) +export class NewDraftComponent { + logicHandler: NewDraftLogicHandler; + + page: (typeof PAGES_BY_ORDER)[number]['page'] | 'loading' | 'pending_updates' | 'abort' = 'loading'; + + pendingProjects: { projectId: string; name: string }[] = []; + + draftingChapterErrors = new Map(); + targetTrainingChapterErrors = new Map(); + stepError: I18nKeyForComponent<'new_draft'> | null = null; + + draftingExclusionsExpanded = false; + trainingExclusionsExpanded = false; + + sendEmailOnBuildFinished: boolean = false; + fastTraining: boolean = false; + useEcho: boolean = false; + isTrainingOptional: boolean = false; + + /** All training data files currently available for the project. */ + trainingDataFiles: TrainingData[] = []; + /** DataIds of the training data files the user has chosen to include in this build. */ + selectedTrainingDataFileIds = new Set(); + private hasInitializedTrainingDataSelection = false; + + // Data that is guarnateed to be loaded post init + initData?: { projectId: string }; + + private currentUserDoc?: UserDoc; + + constructor( + private readonly activatedProjectService: ActivatedProjectService, + private readonly draftSourcesService: DraftSourcesService, + private readonly draftGenerationService: DraftGenerationService, + private readonly progressService: DraftProgressService, + readonly i18n: I18nService, + readonly featureFlags: FeatureFlagService, + protected readonly onlineStatusService: OnlineStatusService, + private readonly userService: UserService, + private readonly router: Router, + private readonly nllbLanguageService: NllbLanguageService, + private readonly paratextService: ParatextService, + private readonly errorReportingService: ErrorReportingService, + private readonly errorHandler: ErrorHandler, + private readonly trainingDataService: TrainingDataService, + private readonly projectService: SFProjectService, + private readonly destroyRef: DestroyRef + ) { + this.logicHandler = new NewDraftLogicHandler( + this.activatedProjectService, + this.draftSourcesService, + this.progressService, + this.destroyRef + ); + + void this.init(); + } + + async init(): Promise { + const [, status] = await Promise.all([ + this.userService.getCurrentUser().then(doc => (this.currentUserDoc = doc)), + firstValueFrom(this.logicHandler.status$.pipe(filter(status => status === 'input' || status === 'abort'))) + ]); + if (status === 'abort') { + this.handleAbort(); + return; + } + this.initData = { + projectId: await firstValueFrom(this.activatedProjectService.projectId$.pipe(filterNullish())) + }; + + this.initTrainingDataFiles(this.initData.projectId); + + const sources = this.logicHandler.sources; + const targetTag = this.activatedProjectService.projectDoc?.data?.writingSystem.tag; + const draftingSourceTag = sources?.draftingSources[0]?.writingSystem?.tag; + if (targetTag != null && draftingSourceTag != null) { + this.isTrainingOptional = + (await this.nllbLanguageService.isNllbLanguageAsync(targetTag)) && + (await this.nllbLanguageService.isNllbLanguageAsync(draftingSourceTag)); + } + + const draftConfig = this.activatedProjectService.projectDoc?.data?.translateConfig?.draftConfig; + this.sendEmailOnBuildFinished = draftConfig?.sendEmailOnBuildFinished ?? false; + if (this.featureFlags.showDeveloperTools.enabled) { + this.fastTraining = draftConfig?.fastTraining ?? false; + this.useEcho = draftConfig?.useEcho ?? false; + } + + if (this.onlineStatusService.isOnline) { + await this.detectPendingUpdates(); + } + if (this.pendingProjects.length > 0) { + this.page = 'pending_updates'; + } else { + this.page = 'preface'; + this.armSyncWatcher(); + } + + this.logicHandler.status$ + .pipe( + filter(s => s === 'abort'), + take(1), + quietTakeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.handleAbort()); + } + + /** + * Anticipated failures (no_access, config_changed, project_syncing) show a blocking abort screen. + * Unanticipated failures go to the app-wide error handler and navigate back so the user isn't stranded. + */ + private handleAbort(): void { + const mode = this.logicHandler.abortMode; + if (mode === 'no_access' || mode === 'config_changed' || mode === 'project_syncing') { + this.page = 'abort'; + return; + } + this.errorHandler.handleError(this.logicHandler.initError); + this.goBack(); + } + + get abortMode(): NewDraftAbortMode { + return this.logicHandler.abortMode; + } + + get inaccessibleProjectNames(): string[] { + return this.logicHandler.inaccessibleProjectNames; + } + + goBack(): void { + void this.router.navigate(['/projects', this.activatedProjectService.projectId, 'draft-generation']); + } + + /** + * Watches training data files. On first load, computes the default selection from the previous build's selection + * and available files (see {@link defaultSelectedTrainingDataFiles}). Later changes keep the list current and + * prune missing files, without clobbering in-session choices. + */ + private initTrainingDataFiles(projectId: string): void { + this.trainingDataService + .getTrainingData(projectId, this.destroyRef) + .pipe(quietTakeUntilDestroyed(this.destroyRef, { logWarnings: false })) + .subscribe(files => { + this.trainingDataFiles = files; + const currentFileIds = files.map(f => f.dataId); + if (!this.hasInitializedTrainingDataSelection) { + const draftConfig = this.activatedProjectService.projectDoc?.data?.translateConfig?.draftConfig; + this.selectedTrainingDataFileIds = new Set( + defaultSelectedTrainingDataFiles( + currentFileIds, + draftConfig?.lastSelectedTrainingDataFiles, + draftConfig?.lastAvailableTrainingDataFiles + ) + ); + this.hasInitializedTrainingDataSelection = true; + } else { + // Prune selections for files that have since been removed. + const stillPresent = new Set(currentFileIds); + this.selectedTrainingDataFileIds = new Set( + [...this.selectedTrainingDataFileIds].filter(id => stillPresent.has(id)) + ); + } + }); + } + + isTrainingDataFileSelected(dataId: string): boolean { + return this.selectedTrainingDataFileIds.has(dataId); + } + + /** Titles of the training data files the user has selected, for the summary recap. */ + get selectedTrainingDataFileTitles(): string[] { + return this.trainingDataFiles.filter(f => this.selectedTrainingDataFileIds.has(f.dataId)).map(f => f.title); + } + + onTrainingDataFileToggled(dataId: string, selected: boolean): void { + // Replace the Set so the template's change detection picks up the change. + const updated = new Set(this.selectedTrainingDataFileIds); + if (selected) { + updated.add(dataId); + } else { + updated.delete(dataId); + } + this.selectedTrainingDataFileIds = updated; + } + + private async detectPendingUpdates(): Promise { + const sources = this.logicHandler.sources; + const projectId = this.initData!.projectId; + const involvedIds = new Set([ + projectId, + ...(sources?.draftingSources.map(s => s.projectRef) ?? []), + ...(sources?.trainingSources.map(s => s.projectRef) ?? []) + ]); + + let projects: ParatextProject[] | undefined; + try { + projects = await this.paratextService.getProjects(); + } catch (error) { + // Detection is advisory; if we can't reach Paratext, proceed rather than stranding the user. + this.errorReportingService.silentError( + 'Failed to check for pending Paratext updates before drafting', + ErrorReportingService.normalizeError(error) + ); + return; + } + this.pendingProjects = (projects ?? []) + .filter(p => p.projectId != null && involvedIds.has(p.projectId) && p.isConnected && p.hasUpdate) + .map(p => ({ + projectId: p.projectId!, + name: p.name?.length ? p.name : p.shortName + })); + } + + /** + * Leaves the pending-updates pre-step. If projects were synced in place, re-derives book/chapter availability + * from fresh progress before showing Step 1 to avoid stale selection UI. + */ + async onPendingUpdatesComplete(syncedProjectIds: string[]): Promise { + if (syncedProjectIds.length === 0) { + this.page = 'preface'; + this.armSyncWatcher(); + return; + } + this.page = 'loading'; + try { + await this.logicHandler.reload(syncedProjectIds); + this.page = 'preface'; + this.armSyncWatcher(); + } catch (error) { + // Mirror init-failure handling: route to the app-wide error handler and navigate back rather than stranding the + // user on the spinner with stale data. + this.errorHandler.handleError(error); + this.goBack(); + } + } + + private syncWatcherArmed = false; + + /** + * Begins watching involved projects for a sync that starts after this point; aborts if one does. Called at + * "lock-in" when the user leaves the pre-step (or immediately if there was none). A project already syncing + * at lock-in is treated as the baseline and does not trigger an abort. + */ + private armSyncWatcher(): void { + if (this.syncWatcherArmed) return; + this.syncWatcherArmed = true; + + const sources = this.logicHandler.sources; + const involvedIds = new Set([ + this.initData!.projectId, + ...(sources?.draftingSources.map(s => s.projectRef) ?? []), + ...(sources?.trainingSources.map(s => s.projectRef) ?? []) + ]); + + for (const projectId of involvedIds) { + void this.watchProjectForSync(projectId); + } + } + + private async watchProjectForSync(projectId: string): Promise { + try { + // The target is already loaded; sources are fetched as profile docs, which also carry sync status. + const projectDoc: SFProjectProfileDoc = + projectId === this.initData?.projectId && this.activatedProjectService.projectDoc != null + ? this.activatedProjectService.projectDoc + : await this.projectService.getProfile(projectId); + + let wasSyncing = projectDoc.data != null && isSFProjectSyncing(projectDoc.data); + projectDoc.remoteChanges$.pipe(quietTakeUntilDestroyed(this.destroyRef)).subscribe(() => { + const data = projectDoc.data; + if (data == null) return; + const syncing = isSFProjectSyncing(data); + // Abort only on the not-syncing → syncing edge; a project already syncing at arm time is the baseline. + if (syncing && !wasSyncing) this.logicHandler.abort('project_syncing'); + wasSyncing = syncing; + }); + } catch (error) { + this.errorReportingService.silentError( + 'Failed to watch a project for sync during drafting', + ErrorReportingService.normalizeError(error) + ); + } + } + + get currentUserEmail(): string | undefined { + return this.currentUserDoc?.data?.email; + } + + get copyrightMessages(): CopyrightMessage[] { + const sources = this.logicHandler.sources; + if (sources == null) return []; + return getCopyrightMessages([...sources.trainingSources, ...sources.draftingSources]); + } + + /** Index of the current page in PAGES_BY_ORDER, or -1 on non-step pages (loading, pending updates, abort). */ + private get currentPageIndex(): number { + return PAGES_BY_ORDER.findIndex(p => p.page === this.page); + } + + /** The 1-based number of the current wizard step, or null on non-step pages (loading, pending updates, abort). */ + get stepNumber(): number | null { + const index = this.currentPageIndex; + return index >= 0 ? index + 1 : null; + } + + /** Total number of wizard steps shown in the step indicator. */ + get totalStepCount(): number { + return PAGES_BY_ORDER.length; + } + + back(): void { + this.step(-1); + } + + next(): void { + this.step(1); + } + + private getForwardError(): I18nKeyForComponent<'new_draft'> | null { + if (this.page === 'draft_books') { + if (this.logicHandler.selectedDraftingScriptureRange.books.size === 0) return 'no_drafting_books_selected'; + if (this.draftingChapterErrors.size > 0) return 'fix_chapter_errors'; + } + if (this.page === 'training_books') { + if (!this.isTrainingOptional && !this.hasTrainingBooksSelected) return 'no_training_books_selected'; + // Unlike the "select something" requirement above, an unpaired book is an inconsistent state, not a missing + // optional choice: the user chose to train on a book but then deselected the matching reference book from every + // source, so their selection leaves nothing to pair it with. Block it regardless of whether training is optional. + if (this.unpairedTargetTrainingBooks.length > 0) return 'no_training_pair_selected'; + if (this.targetTrainingChapterErrors.size > 0) return 'fix_chapter_errors'; + } + return null; + } + + /** + * Selected target training books with no matching book in any training source. This is only non-empty after + * the user manually deselects a previously auto-paired book from all sources. + */ + private get unpairedTargetTrainingBooks(): string[] { + const targetBooks = Array.from(this.logicHandler.selectedTargetTrainingScriptureRange.books.keys()); + const selectedSourceBooks = new Set(Object.values(this.logicHandler.selectedTrainingSourceBooks).flat()); + return targetBooks.filter(bookId => !selectedSourceBooks.has(bookId)); + } + + private get hasTrainingBooksSelected(): boolean { + const hasTargetBooks = this.logicHandler.selectedTargetTrainingScriptureRange.books.size > 0; + if (!hasTargetBooks) return false; + if (this.trainingSources.length === 0) return true; + const selected = this.logicHandler.selectedTrainingSourceBooks; + return Object.values(selected).some(books => books.length > 0); + } + + private clearStepErrorIfResolved(): void { + if (this.stepError != null && this.getForwardError() !== this.stepError) this.stepError = null; + } + + private step(count: 1 | -1): void { + if (count === 1) { + const error = this.getForwardError(); + if (error != null) { + this.stepError = error; + return; + } + } + this.stepError = null; + const newIndex = this.currentPageIndex + count; + if (newIndex < 0) { + void this.router.navigate(['/projects', this.initData?.projectId, 'draft-generation']); + } else if (newIndex < PAGES_BY_ORDER.length) { + const newPage = PAGES_BY_ORDER[newIndex]; + this.page = newPage.page; + if (hasStringProp(newPage, 'inputState')) { + this.draftingChapterErrors.clear(); + this.targetTrainingChapterErrors.clear(); + this.logicHandler.setInputMode(newPage.inputState as 'draft_books' | 'training_books'); + } + } else throw new Error(`Cannot navigate from page ${this.page} to index ${newIndex}`); + } + + submitting = false; + async generateDraftClicked(): Promise { + if (!this.onlineStatusService.isOnline || this.initData == null) return; + + this.submitting = true; + + await Promise.resolve(); + + try { + const projectId = this.initData.projectId; + const draftingSource = this.logicHandler.sources?.draftingSources[0]; + if (draftingSource == null) return; + + const translationScriptureRanges: ProjectScriptureRange[] = [ + { + projectId: draftingSource.projectRef, + scriptureRange: this.logicHandler.selectedDraftingScriptureRange.toString() + } + ]; + + const trainingScriptureRanges: ProjectScriptureRange[] = []; + const selectedSrcBooks = this.logicHandler.selectedTrainingSourceBooks; + for (const source of this.logicHandler.sources?.trainingSources ?? []) { + const bookIds = selectedSrcBooks[source.projectRef] ?? []; + if (bookIds.length > 0) { + trainingScriptureRanges.push({ projectId: source.projectRef, scriptureRange: bookIds.join(';') }); + } + } + // Include target project entry at chapter-level: persists training selection and drives backend filter + trainingScriptureRanges.push({ + projectId, + scriptureRange: this.logicHandler.selectedTargetTrainingScriptureRange.toString() + }); + + // Record available files alongside the selection so later builds can distinguish new files from deliberately deselected ones. + const availableTrainingDataFiles = this.trainingDataFiles.map(f => f.dataId); + const trainingDataFiles = availableTrainingDataFiles.filter(id => this.selectedTrainingDataFileIds.has(id)); + + const buildConfig: BuildConfig = { + projectId, + translationScriptureRanges, + trainingScriptureRanges, + trainingDataFiles, + availableTrainingDataFiles, + fastTraining: this.fastTraining, + useEcho: this.useEcho, + sendEmailOnBuildFinished: this.sendEmailOnBuildFinished + }; + + await firstValueFrom(this.draftGenerationService.startBuildOrGetActiveBuild(buildConfig)); + + void this.router.navigate(['/projects', projectId, 'draft-generation']); + } finally { + this.submitting = false; + } + } + + /** + * Maps a range's books to the `Book[]` shape the multi-select expects. With no `selectedBookIds`, every book is + * marked selected (the range itself is the selection); otherwise a book is selected iff its id is in `selectedBookIds`. + */ + private toBookList(range: VerboseScriptureRange, selectedBookIds?: Set): Book[] { + return scriptureRangeToBookListWithoutChapterDetail(range).map(id => ({ + number: Canon.bookIdToNumber(id), + selected: selectedBookIds == null || selectedBookIds.has(id) + })); + } + + // Section: Drafting books selection + + get availableDraftingBooks(): Book[] { + return this.toBookList( + this.logicHandler.availableDraftingScriptureRange, + new Set(this.logicHandler.selectedDraftingScriptureRange.books.keys()) + ); + } + + get selectedDraftingBooks(): Book[] { + return this.toBookList(this.logicHandler.selectedDraftingScriptureRange); + } + + get booksOfferedForPartialDrafting(): string[] { + return this.logicHandler.booksOfferedForPartialDrafting; + } + + /** Drafting-exclusion reasons shown to the user, in display order. Non-canonical exclusions are never surfaced. */ + private readonly surfacedDraftingExclusionReasons: DraftingBookExclusionReason[] = [ + 'no_source_content', + 'not_in_target' + ]; + + /** How many books are hidden from the drafting list for a surfaced reason (used by the "N books are hidden" toggle). */ + get draftingHiddenBookCount(): number { + return this.logicHandler.excludedDraftingBooks.filter(book => + this.surfacedDraftingExclusionReasons.includes(book.reason) + ).length; + } + + /** + * Notices explaining books the user might expect that were left out of the drafting list, one per surfaced reason. + * Non-canonical exclusions are intentionally tracked but not surfaced. Each entry carries the i18n key for its + * reason and the parameters that message needs. + */ + get draftingExclusionNotices(): { key: I18nKeyForComponent<'new_draft'>; params: Record }[] { + const excluded = this.logicHandler.excludedDraftingBooks; + return this.surfacedDraftingExclusionReasons + .map(reason => { + const bookNames = excluded + .filter(book => book.reason === reason) + .map(book => this.i18n.localizeBook(book.bookId)); + return { reason, bookNames }; + }) + .filter(group => group.bookNames.length > 0) + .map(group => ({ + key: `draft_books.excluded_${group.reason}` as I18nKeyForComponent<'new_draft'>, + params: { + books: this.i18n.enumerateList(group.bookNames), + source: this.draftingSourceName, + project: this.targetProjectDisplayName + } + })); + } + + /** Drops chapter-input errors for books that are no longer offered for partial selection (so have no input). */ + private pruneChapterErrors(errors: Map, offeredBookIds: string[]): void { + for (const bookId of errors.keys()) { + if (!offeredBookIds.includes(bookId)) errors.delete(bookId); + } + } + + onDraftingBookSelect(books: number[]): void { + const selectedBookIds = books.map(b => Canon.bookNumberToId(b)); + this.logicHandler.selectDraftingBooks(selectedBookIds); + this.pruneChapterErrors(this.draftingChapterErrors, this.logicHandler.booksOfferedForPartialDrafting); + this.clearStepErrorIfResolved(); + } + + /** + * Parses a chapter-range input, recording an error and returning null on failure. An empty input is rejected + * with `emptyErrorKey` because clearing the input is not how a book is removed. The emptyErrorKey allows the caller + * to specify which I18nKey to return when the input is empty. + */ + private parseChapterInput( + bookId: string, + value: string, + errors: Map, + emptyErrorKey: I18nKeyForComponent<'new_draft'> + ): ChapterSet | null { + let parsed: ChapterSet; + try { + parsed = ChapterSet.fromUserInput(value); + } catch { + errors.set(bookId, { key: 'chapter_input.invalid_range' }); + return null; + } + // Checked after parsing: fromUserInput normalizes whitespace and separators away, so empty, whitespace-only, and + // separator-only input all resolve to an empty set here. + if (parsed.count() === 0) { + errors.set(bookId, { key: emptyErrorKey }); + return null; + } + return parsed; + } + + onDraftingChaptersBlurred(bookId: string, value: string): void { + const parsed = this.parseChapterInput(bookId, value, this.draftingChapterErrors, 'chapter_input.empty_draft'); + if (parsed == null) return; + + const available = this.logicHandler.availableDraftingScriptureRange.books.get(bookId); + const badChapters = available != null ? parsed.difference(available) : parsed; + if (badChapters.count() > 0) { + this.draftingChapterErrors.set(bookId, { + key: 'chapter_input.chapters_not_in_source', + params: { + chapters: badChapters.toString(), + sourceName: this.logicHandler.sources?.draftingSources[0]?.shortName ?? '' + } + }); + return; + } + + this.draftingChapterErrors.delete(bookId); + this.logicHandler.selectDraftingChapters(bookId, value); + } + + draftingRangeForBook(bookId: string): string { + const range = this.logicHandler.selectedDraftingScriptureRange; + return range.books.get(bookId)?.toString() ?? ''; + } + + draftingChapterHint(bookId: string): string { + return this.logicHandler.availableDraftingScriptureRange.books.get(bookId)?.toString() ?? ''; + } + + // Section: Target training books selection + + get availableTargetTrainingBooks(): Book[] { + return this.toBookList( + this.logicHandler.availableTargetTrainingScriptureRange, + new Set(this.logicHandler.selectedTargetTrainingScriptureRange.books.keys()) + ); + } + + get selectedTargetTrainingBooks(): Book[] { + return this.toBookList(this.logicHandler.selectedTargetTrainingScriptureRange); + } + + get booksOfferedForPartialTargetTraining(): string[] { + return this.logicHandler.booksOfferedForPartialTargetTraining; + } + + /** Whether any target book is hidden from the training list for lacking a matching book in any training source. */ + get hasTargetTrainingBooksWithoutSource(): boolean { + return this.logicHandler.targetTrainingBooksWithoutSource.length > 0; + } + + /** How many target books are hidden from the training list (used by the "N books are hidden" toggle). */ + get targetTrainingHiddenBookCount(): number { + return this.logicHandler.targetTrainingBooksWithoutSource.length; + } + + /** Whether training books were pre-selected on this project's first draft (shows the "review these" notice). */ + get trainingBooksWereAutoSelected(): boolean { + return this.logicHandler.trainingBooksWereAutoSelected; + } + + /** Localized, comma-joined names of the target books hidden from the training list for lacking a training source. */ + get targetTrainingBooksWithoutSourceNames(): string { + return this.i18n.enumerateList( + this.logicHandler.targetTrainingBooksWithoutSource.map(bookId => this.i18n.localizeBook(bookId)) + ); + } + + onTargetTrainingBookSelect(books: number[]): void { + const previousSelectedTargetIds = new Set( + scriptureRangeToBookListWithoutChapterDetail(this.logicHandler.selectedTargetTrainingScriptureRange) + ); + const newSelectedIds = new Set(books.map(n => Canon.bookNumberToId(n))); + const addedIds = [...newSelectedIds].filter(id => !previousSelectedTargetIds.has(id)); + + this.logicHandler.selectTargetTrainingBooks([...newSelectedIds]); + this.pruneChapterErrors(this.targetTrainingChapterErrors, this.logicHandler.booksOfferedForPartialTargetTraining); + + // Auto-select newly added target books in each training source; drop removed books + for (const source of this.trainingSources) { + const available = this.logicHandler.availableTrainingSourceBooks[source.projectRef] ?? []; + const currentSelected = this.logicHandler.selectedTrainingSourceBooks[source.projectRef] ?? []; + const stillValid = currentSelected.filter(id => newSelectedIds.has(id)); + const autoAdded = addedIds.filter(id => available.includes(id)); + this.logicHandler.selectTrainingSourceBooks(source.projectRef, [...new Set([...stillValid, ...autoAdded])]); + } + this.logicHandler.dismissAutoSelectNoticeIfSelectionEmpty(); + this.clearStepErrorIfResolved(); + } + + onTargetTrainingChaptersBlurred(bookId: string, value: string): void { + const parsed = this.parseChapterInput( + bookId, + value, + this.targetTrainingChapterErrors, + 'chapter_input.empty_training' + ); + if (parsed == null) return; + + const available = this.logicHandler.availableTargetTrainingScriptureRange.books.get(bookId); + const unavailable = available != null ? parsed.difference(available) : parsed; + + if (unavailable.count() > 0) { + const drafted = this.logicHandler.selectedDraftingScriptureRange.books.get(bookId); + const draftedUnavailable = drafted != null ? unavailable.intersection(drafted) : new ChapterSet([]); + if (draftedUnavailable.count() > 0) { + this.targetTrainingChapterErrors.set(bookId, { + key: 'chapter_input.chapters_will_be_translated', + params: { chapters: draftedUnavailable.toString() } + }); + } else { + const targetName = this.activatedProjectService.projectDoc?.data?.shortName ?? ''; + this.targetTrainingChapterErrors.set(bookId, { + key: 'chapter_input.chapters_not_in_target', + params: { chapters: unavailable.toString(), targetName } + }); + } + return; + } + + this.targetTrainingChapterErrors.delete(bookId); + this.logicHandler.selectTargetTrainingChapters(bookId, value); + } + + targetTrainingRangeForBook(bookId: string): string { + const range = this.logicHandler.selectedTargetTrainingScriptureRange; + return range.books.get(bookId)?.toString() ?? ''; + } + + targetTrainingChapterHint(bookId: string): string { + return this.logicHandler.availableTargetTrainingScriptureRange.books.get(bookId)?.toString() ?? ''; + } + + // Section: Training source book selection + + get trainingSources(): DraftSource[] { + return this.logicHandler.sources?.trainingSources ?? []; + } + + onTrainingSourceBookSelect(books: number[], projectId: string): void { + const bookIds = books.map(b => Canon.bookNumberToId(b)); + this.logicHandler.selectTrainingSourceBooks(projectId, bookIds); + this.clearStepErrorIfResolved(); + } + + availableTrainingSourceBooksForProject(projectId: string): Book[] { + const bookIds = this.logicHandler.availableTrainingSourceBooks[projectId] ?? []; + const selectedTargetIds = new Set( + scriptureRangeToBookListWithoutChapterDetail(this.logicHandler.selectedTargetTrainingScriptureRange) + ); + const selectedIds = this.logicHandler.selectedTrainingSourceBooks[projectId] ?? []; + return bookIds + .filter(id => selectedTargetIds.has(id)) + .map(id => ({ number: Canon.bookIdToNumber(id), selected: selectedIds.includes(id) })); + } + + selectedTrainingSourceBooksForProject(projectId: string): Book[] { + const bookIds = this.logicHandler.selectedTrainingSourceBooks[projectId] ?? []; + return bookIds.map(id => ({ number: Canon.bookIdToNumber(id), selected: true })); + } + + // Section: Summary (Step 4) + + get draftingItems(): { bookId: string; chapterRange: string | null }[] { + const selectedRange = this.logicHandler.selectedDraftingScriptureRange; + const availableRange = this.logicHandler.availableDraftingScriptureRange; + return Array.from(selectedRange.books.entries()) + .sort(([a], [b]) => Canon.bookIdToNumber(a) - Canon.bookIdToNumber(b)) + .map(([bookId, selected]) => { + const available = availableRange.books.get(bookId); + const isPartial = available != null && available.difference(selected).count() > 0; + return { bookId, chapterRange: isPartial ? selected.toStringForDisplay() : null }; + }); + } + + /** Selected drafting book names with chapter ranges (for partially-drafted books), as bulleted-list items. */ + get draftingBookListItems(): string[] { + return this.draftingItems.map(item => { + const bookName = this.i18n.localizeBook(item.bookId); + return item.chapterRange != null ? `${bookName} (${item.chapterRange})` : bookName; + }); + } + + /** Selected drafting book names without chapter detail, as a locale-aware conjunction list (for the page title). */ + get draftingBookNamesFormatted(): string { + // draftingItems is already canon-sorted, so reuse it rather than re-sorting the selected range here. + return this.i18n.enumerateList(this.draftingItems.map(item => this.i18n.localizeBook(item.bookId))); + } + + /** One row per training source that has selected books, for the summary's training-books table. */ + get sourceTrainingSections(): { projectRef: string; shortName: string; bookNumbers: number[] }[] { + return this.trainingSources + .map(source => { + const bookIds = this.logicHandler.selectedTrainingSourceBooks[source.projectRef] ?? []; + return { + projectRef: source.projectRef, + shortName: source.shortName, + bookNumbers: bookIds.map(id => Canon.bookIdToNumber(id)).sort((a, b) => a - b) + }; + }) + .filter(s => s.bookNumbers.length > 0); + } + + /** + * Localized training-book list for a source row in the summary table. Fully-used books collapse into ranges; + * a book that is only partly used as training data is shown with its chapter range (and broken out of any range). + */ + formatTrainingBooks(bookNumbers: number[]): string { + return formatTrainingBooksSummary( + bookNumbers, + this.logicHandler.selectedTargetTrainingScriptureRange, + this.logicHandler.availableTargetTrainingScriptureRange, + this.logicHandler.selectedDraftingScriptureRange, + this.i18n + ); + } + + get draftingSourceName(): string { + return this.logicHandler.sources?.draftingSources[0]?.shortName ?? ''; + } + + /** The drafting source's "shortName - name" label (shown in the draft-books summary subtitle). */ + get draftingSourceLabel(): string { + const draftingSource = this.logicHandler.sources?.draftingSources[0]; + return draftingSource != null ? projectLabel(draftingSource) : ''; + } + + get sourceLanguageDisplay(): string { + const tag = this.logicHandler.sources?.draftingSources[0]?.writingSystem?.tag; + return tag != null ? this.languageDisplay(tag) : ''; + } + + get targetLanguageDisplay(): string { + const tag = this.activatedProjectService.projectDoc?.data?.writingSystem?.tag; + return tag != null ? this.languageDisplay(tag) : ''; + } + + private languageDisplay(tag: string): string { + const name = this.i18n.getLanguageDisplayName(tag); + return name === tag ? tag : `${name} (${tag})`; + } + + get targetProjectDisplayName(): string { + const projectData = this.activatedProjectService.projectDoc?.data; + return projectData != null ? projectLabel(projectData) : ''; + } + + get targetShortName(): string { + return this.activatedProjectService.projectDoc?.data?.shortName ?? ''; + } + + /** Plain language name (no tag) for the training-source column header. Matches the legacy stepper's summary. */ + get sourceTrainingLanguageName(): string { + const tag = this.trainingSources[0]?.writingSystem?.tag; + return tag != null ? (this.i18n.getLanguageDisplayName(tag) ?? tag) : ''; + } + + /** Plain language name (no tag) for the target column header. */ + get targetLanguageName(): string { + const tag = this.activatedProjectService.projectDoc?.data?.writingSystem?.tag; + return tag != null ? (this.i18n.getLanguageDisplayName(tag) ?? tag) : ''; + } + + /** Whether an administrator has applied a custom Serval config to this project */ + get isCustomConfigSet(): boolean { + return this.activatedProjectService.projectDoc?.data?.translateConfig?.draftConfig?.servalConfig != null; + } + + goToConfigureSources(): void { + const projectId = this.initData?.projectId; + if (projectId != null) { + void this.router.navigate(['/projects', projectId, 'draft-generation', 'configure-sources']); + } + } + + goToPage(page: 'draft_books' | 'training_books'): void { + this.stepError = null; + if (page === 'draft_books') { + this.logicHandler.setInputMode('draft_books'); + } + this.page = page; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.spec.ts new file mode 100644 index 00000000000..bc4b2950790 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.spec.ts @@ -0,0 +1,65 @@ +import { defaultSelectedTrainingDataFiles } from './training-data-file-selection'; + +describe('defaultSelectedTrainingDataFiles', () => { + describe('when the available set was recorded last time (new format)', () => { + it('keeps files that were used last time and are still present', () => { + const result = defaultSelectedTrainingDataFiles(['a', 'b', 'c'], ['a', 'c'], ['a', 'b', 'c']); + expect(result).toEqual(['a', 'c']); + }); + + it('selects newly added files (present now, not offered last time)', () => { + // 'd' is new (was not available at the last build), so it defaults to selected alongside the prior selection + const result = defaultSelectedTrainingDataFiles(['a', 'b', 'c', 'd'], ['a'], ['a', 'b', 'c']); + expect(result).toEqual(['a', 'd']); + }); + + it('leaves files that were offered but not used last time deselected', () => { + // 'b' was available last time but not selected → it was deliberately deselected, so it stays off + const result = defaultSelectedTrainingDataFiles(['a', 'b'], ['a'], ['a', 'b']); + expect(result).toEqual(['a']); + }); + + it('drops previously selected files that no longer exist', () => { + const result = defaultSelectedTrainingDataFiles(['a'], ['a', 'z'], ['a', 'z']); + expect(result).toEqual(['a']); + }); + + it('treats an empty recorded available set as "everything is new" (all selected)', () => { + const result = defaultSelectedTrainingDataFiles(['a', 'b'], [], []); + expect(result).toEqual(['a', 'b']); + }); + + it('preserves the current-file ordering', () => { + const result = defaultSelectedTrainingDataFiles(['c', 'a', 'b'], ['a', 'b', 'c'], ['a', 'b', 'c']); + expect(result).toEqual(['c', 'a', 'b']); + }); + }); + + describe('legacy fallback (available set not recorded)', () => { + it('follows the last selection when one exists', () => { + // lastAvailable undefined → cannot detect new files; respect the last selection as-is + const result = defaultSelectedTrainingDataFiles(['a', 'b', 'c'], ['a', 'c'], undefined); + expect(result).toEqual(['a', 'c']); + }); + + it('does not auto-select newly added files when there is a prior selection', () => { + const result = defaultSelectedTrainingDataFiles(['a', 'b', 'c'], ['a'], undefined); + expect(result).toEqual(['a']); + }); + + it('selects everything when there is no prior selection at all', () => { + const result = defaultSelectedTrainingDataFiles(['a', 'b'], [], undefined); + expect(result).toEqual(['a', 'b']); + }); + + it('selects everything when the prior selection is also undefined', () => { + const result = defaultSelectedTrainingDataFiles(['a', 'b'], undefined, undefined); + expect(result).toEqual(['a', 'b']); + }); + }); + + it('returns an empty list when there are no current files', () => { + expect(defaultSelectedTrainingDataFiles([], ['a'], ['a'])).toEqual([]); + expect(defaultSelectedTrainingDataFiles([], undefined, undefined)).toEqual([]); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.ts new file mode 100644 index 00000000000..dea384377e8 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-file-selection.ts @@ -0,0 +1,33 @@ +/** + * Computes which training data files should be selected by default when the new draft wizard opens. + * + * The intent: + * - Files used at the last build (and still present) start selected. + * - Newly added files (present now, but not offered at the last build) start selected. + * - Files that were offered at the last build but not used start deselected (the user deliberately excluded them). + * + * Distinguishing a newly added file from a deliberately deselected one requires knowing which files were available + * at the last build (`lastAvailableFileIds`). Builds made before that was recorded won't have it, so we fall back to + * following the last selection if one exists, or selecting everything when there is no prior selection at all. + * + * The returned list preserves the order of `currentFileIds`. + */ +export function defaultSelectedTrainingDataFiles( + currentFileIds: string[], + lastSelectedFileIds: string[] | undefined, + lastAvailableFileIds: string[] | undefined +): string[] { + const lastSelected = new Set(lastSelectedFileIds ?? []); + + if (lastAvailableFileIds != null) { + // New format: we know what was offered last time, so we can tell newly added files apart from deselected ones. + const lastAvailable = new Set(lastAvailableFileIds); + return currentFileIds.filter(id => lastSelected.has(id) || !lastAvailable.has(id)); + } + + // Legacy fallback: no record of what was offered last time. + if (lastSelected.size > 0) { + return currentFileIds.filter(id => lastSelected.has(id)); + } + return [...currentFileIds]; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.spec.ts new file mode 100644 index 00000000000..b3a2c33d771 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.spec.ts @@ -0,0 +1,103 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslocoService } from '@ngneat/transloco'; +import { Canon } from '@sillsdev/scripture'; +import { CookieService } from 'ngx-cookie-service'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { BugsnagService } from 'xforge-common/bugsnag.service'; +import { ErrorReportingService } from 'xforge-common/error-reporting.service'; +import { I18nService } from 'xforge-common/i18n.service'; +import { LocationService } from 'xforge-common/location.service'; +import { getTestTranslocoModule } from 'xforge-common/test-utils'; +import { VerboseScriptureRange } from '../../../shared/scripture-range'; +import { formatTrainingBooksSummary } from './training-data-summary'; + +const mockedLocationService = mock(LocationService); +const mockedBugsnagService = mock(BugsnagService); +const mockedCookieService = mock(CookieService); +const mockedErrorReportingService = mock(ErrorReportingService); +const mockedDocument = mock(Document); + +describe('formatTrainingBooksSummary', () => { + const GEN = Canon.bookIdToNumber('GEN'); + const EXO = Canon.bookIdToNumber('EXO'); + const LEV = Canon.bookIdToNumber('LEV'); + const NUM = Canon.bookIdToNumber('NUM'); + + let i18n: I18nService; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [getTestTranslocoModule()] }); + when(mockedLocationService.search).thenReturn(''); + when(mockedCookieService.get(anything())).thenReturn(''); + // A genuine I18nService backed by the test transloco module (real 'en' translations, including book names). + // ignoreCookieLocale=true keeps the constructor from reading the cookie locale, so the dependencies are only + // present to satisfy construction. + i18n = new I18nService( + instance(mockedLocationService), + instance(mockedBugsnagService), + TestBed.inject(TranslocoService), + instance(mockedCookieService), + instance(mockedErrorReportingService), + instance(mockedDocument), + true + ); + }); + + function format(bookNumbers: number[], selected: string, available: string, drafting = ''): string { + return formatTrainingBooksSummary( + bookNumbers, + new VerboseScriptureRange(selected), + new VerboseScriptureRange(available), + new VerboseScriptureRange(drafting), + i18n + ); + } + + it('collapses fully-used books into a single range', () => { + expect(format([GEN, EXO, LEV], 'GEN1-50;EXO1-40;LEV1-27', 'GEN1-50;EXO1-40;LEV1-27')).toEqual( + 'Genesis - Leviticus' + ); + }); + + it('renders a single fully-used book as its name', () => { + expect(format([EXO], 'EXO1-40', 'EXO1-40')).toEqual('Exodus'); + }); + + it('breaks a partial book in the middle out of the range and shows its chapters', () => { + expect(format([GEN, EXO, LEV], 'GEN1-50;EXO1-10;LEV1-27', 'GEN1-50;EXO1-40;LEV1-27')).toEqual( + 'Genesis, Exodus (1-10), and Leviticus' + ); + }); + + it('handles a partial book at the start', () => { + expect(format([GEN, EXO, LEV], 'GEN1-10;EXO1-40;LEV1-27', 'GEN1-50;EXO1-40;LEV1-27')).toEqual( + 'Genesis (1-10) and Exodus - Leviticus' + ); + }); + + it('handles a partial book at the end', () => { + expect(format([GEN, EXO, LEV], 'GEN1-50;EXO1-40;LEV1-10', 'GEN1-50;EXO1-40;LEV1-27')).toEqual( + 'Genesis - Exodus and Leviticus (1-10)' + ); + }); + + it('does not merge full books across a partial book', () => { + expect(format([GEN, EXO, LEV, NUM], 'GEN1-50;EXO1-5;LEV1-27;NUM1-36', 'GEN1-50;EXO1-40;LEV1-27;NUM1-36')).toEqual( + 'Genesis, Exodus (1-5), and Leviticus - Numbers' + ); + }); + + it('shows the training chapter range for a partially-drafted book whose remaining chapters are all selected', () => { + // Draft the latter two-thirds (GEN18-50); the complete first third (GEN1-17) is available and fully selected. + expect(format([GEN], 'GEN1-17', 'GEN1-17', 'GEN18-50')).toEqual('Genesis (1-17)'); + }); + + it('formats non-contiguous selected chapters with spaces after commas', () => { + expect(format([GEN], 'GEN1-3,7', 'GEN1-50')).toEqual('Genesis (1-3, 7)'); + }); + + it('treats a book missing from the target training selection as full (defensive)', () => { + // EXO has no entry in the selection; it should not crash and should render as a full book. + expect(format([GEN, EXO], 'GEN1-50', 'GEN1-50;EXO1-40')).toEqual('Genesis - Exodus'); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.ts new file mode 100644 index 00000000000..16a4d998dfa --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/training-data-summary.ts @@ -0,0 +1,82 @@ +import { Canon } from '@sillsdev/scripture'; +import { I18nService } from 'xforge-common/i18n.service'; +import { ChapterSet, VerboseScriptureRange } from '../../../shared/scripture-range'; + +/** + * A contiguous run of fully-used training books (rendered as a book range), or a single book that is only partly + * used as training data (rendered with its chapter range). Partial books are never absorbed into a range. + */ +export type TrainingBookSegment = + | { kind: 'range'; bookNumbers: number[] } + | { kind: 'partial'; bookId: string; chapterRange: string }; + +const EMPTY_CHAPTER_SET = new ChapterSet(''); + +/** + * Splits a list of training book numbers into display segments. + * + * A book counts as "full" (name only) when its training selection covers the whole book: every chapter that is + * either available for training *or* being drafted. Adjacent full books are grouped into a single `range` segment. + * A book whose selection is only part of the book is a `partial` segment carrying its chapter range, and it breaks + * any surrounding range (so a partial book in the middle of otherwise-full books is never collapsed into a range). + * + * Including the drafted chapters in the "whole book" baseline means a partially-drafted book always shows its + * training chapter range: if you draft the latter two-thirds of a book and train on the complete first third, the + * book is not fully used as training data, so its chapter range is shown. + */ +export function segmentTrainingBooks( + bookNumbers: number[], + selectedTargetTraining: VerboseScriptureRange, + availableTargetTraining: VerboseScriptureRange, + selectedDrafting: VerboseScriptureRange +): TrainingBookSegment[] { + const segments: TrainingBookSegment[] = []; + let pendingFullBooks: number[] = []; + const flushFullBooks = (): void => { + if (pendingFullBooks.length > 0) { + segments.push({ kind: 'range', bookNumbers: pendingFullBooks }); + pendingFullBooks = []; + } + }; + + for (const bookNumber of [...bookNumbers].sort((a, b) => a - b)) { + const bookId = Canon.bookNumberToId(bookNumber); + const selected = selectedTargetTraining.books.get(bookId); + const available = availableTargetTraining.books.get(bookId) ?? EMPTY_CHAPTER_SET; + const drafted = selectedDrafting.books.get(bookId) ?? EMPTY_CHAPTER_SET; + const wholeBook = available.union(drafted); + const isPartial = selected != null && wholeBook.difference(selected).count() > 0; + if (isPartial) { + flushFullBooks(); + segments.push({ kind: 'partial', bookId, chapterRange: selected!.toStringForDisplay() }); + } else { + pendingFullBooks.push(bookNumber); + } + } + flushFullBooks(); + return segments; +} + +/** + * Localized, conjunction-joined training-book list for the summary table (e.g. "Genesis, Exodus (1-10), and + * Leviticus"). Full books collapse into ranges; partial books show their chapters. + */ +export function formatTrainingBooksSummary( + bookNumbers: number[], + selectedTargetTraining: VerboseScriptureRange, + availableTargetTraining: VerboseScriptureRange, + selectedDrafting: VerboseScriptureRange, + i18n: I18nService +): string { + const labels = segmentTrainingBooks( + bookNumbers, + selectedTargetTraining, + availableTargetTraining, + selectedDrafting + ).map(segment => + segment.kind === 'range' + ? i18n.formatAndLocalizeBookRange(segment.bookNumbers) + : `${i18n.localizeBook(segment.bookId)} (${segment.chapterRange})` + ); + return i18n.enumerateList(labels); +} 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 1986c97aabb..6d8ab20c1dd 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 @@ -229,6 +229,91 @@ "warning_generation_faulted": "Last generation attempt failed. Click [em]\"{{ generateButtonText }}\"[/em] below to try again.", "click_book_to_preview": "Click a book below to preview the draft and add it to your project." }, + "new_draft": { + "offline_message": "Draft generation is not available offline.", + "loading": "Loading…", + "step_indicator": "Step {{ step }} of {{ total }}", + "abort": { + "config_changed": "The draft configuration has changed. Please go back and try again.", + "project_syncing": "A project started syncing, which can change the available books and chapters. Please go back and try again.", + "no_access_list_heading": "The following projects could not be accessed:", + "no_access_instruction": "Check that you have access and try again.", + "no_access": "Some projects could not be accessed. Check that you have access and try again.", + "go_back": "Go back" + }, + "chapter_input": { + "available": "Available chapters: {{ range }}", + "invalid_range": "Invalid chapter range.", + "empty_draft": "Enter the chapters to draft, or deselect this book if you don't want to draft it.", + "empty_training": "Enter the chapters to use for training, or deselect this book if you don't want to train on it.", + "chapters_not_in_source": "Chapters {{ chapters }} cannot be generated because they are not in the source project, {{ sourceName }}.", + "chapters_not_in_target": "Chapters {{ chapters }} cannot be used for training because they are not in the main project, {{ targetName }}.", + "chapters_will_be_translated": "Chapters {{ chapters }} cannot be used for training because they are going to be translated." + }, + "change_source_configuration": "Change source configuration", + "books_hidden": { + "show_why": "{{ numBooks }} books are hidden. {{ underlineStart }}Show why{{ underlineEnd }}", + "hide": "{{ numBooks }} books are hidden. {{ underlineStart }}Hide explanation{{ underlineEnd }}" + }, + "training_files": "Training data files", + "fix_chapter_errors": "Please fix the chapter range errors above before continuing.", + "no_drafting_books_selected": "Select at least one book to draft before continuing.", + "no_training_books_selected": "Select training books from your project and at least one source project before continuing.", + "no_training_pair_selected": "Some of the books you selected to train on don't have a matching reference book selected. Select a matching reference book for each.", + "reference_books": "Reference books", + "summary": { + "example_translations": "Language training data", + "no_training_books": "No training books selected.", + "custom_configurations_apply": "Custom draft configurations have been applied by an administrator.", + "settings": "Settings", + "choose_different_books": "Choose different books", + "choose_different_training_data": "Choose different training data", + "example_translations_subtitle": "Scripture Forge uses training data (example translations) to learn how to translate from {{ boldStart }}{{ sourceLanguage }}{{ boldEnd }} into {{ boldStart }}{{ targetLanguage }}{{ boldEnd }}.", + "email_me": "Email me at {{ email }} when the draft is finished.", + "draft_title": "Draft {{ books }}", + "draft_books": "Draft books", + "training_books": "Training books", + "draft_books_subtitle": "The following books in {{ boldStart }}{{ source }}{{ boldEnd }} will be translated from {{ boldStart }}{{ sourceLanguage }}{{ boldEnd }} to {{ boldStart }}{{ targetLanguage }}{{ boldEnd }}" + }, + "pending_updates": { + "heading": "Sync before drafting", + "body": "These projects have changes in Paratext that aren't reflected here yet. Drafting without syncing may reduce draft quality.", + "sync": "Sync", + "sync_all": "Sync all", + "continue_anyway": "Continue anyway", + "synced": "Up to date", + "sync_failed": "Sync failed", + "retry": "Retry", + "cant_sync": "Has changes — ask a project admin to sync", + "wait_for_sync": "Wait for the sync to finish before continuing." + }, + "draft_books": { + "title": "Select books to draft", + "subtitle": "Which books in {{ boldStart }}{{ project }}{{ boldEnd }} do you want to translate from {{ boldStart }}{{ sourceLanguage }}{{ boldEnd }} to {{ boldStart }}{{ targetLanguage }}{{ boldEnd }}?", + "which_chapters": "Which chapters of each book do you want to draft?", + "partial_explanation": "Some of the chapters in the books you selected already have text.", + "learn_more": "Learn more about drafting part of a book.", + "excluded_no_source_content": "These books aren't available to draft because {{ source }} has no text for them: {{ books }}.", + "excluded_not_in_target": "These books aren't available to draft because they aren't in {{ project }} yet: {{ books }}.", + "no_available_books": "You have no books available for drafting." + }, + "training_books": { + "title": "Select books to train on", + "subtitle": "Which books in {{ boldStart }}{{ project }}{{ boldEnd }} are good enough to use to train the model to translate from {{ boldStart }}{{ sourceLanguage }}{{ boldEnd }} to {{ boldStart }}{{ targetLanguage }}{{ boldEnd }}?", + "which_chapters": "Which chapters of each book do you want to use to train the model?", + "partial_explanation": "Some of the chapters in the books you selected will be translated and cannot be used as training data.", + "learn_more": "Learn more about training on part of a book.", + "excluded_not_in_any_source": "These books aren't available to train on because they aren't in any of the reference projects: {{ books }}.", + "auto_selected": "Books that appear to be complete have been pre-selected. Please review and update the training data that should be included.", + "no_target_books": "No books from your project are available for training.", + "reference_books_will_appear": "Reference books will appear here as you select books from your project." + }, + "navigation": { + "back": "Back", + "next": "Next", + "generate_draft": "Generate Draft" + } + }, "draft_generation_steps": { "back": "Back", "books_are_hidden_hide_explanation": "{{ numBooks }} books are hidden. {{ underlineStart }}Hide explanation{{ underlineEnd }}", diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.html b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.html new file mode 100644 index 00000000000..3be39aa72b3 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.html @@ -0,0 +1,24 @@ + +

+ science {{ t("title") }} + +

+ +

{{ t("description") }}

+ + @for (feature of availableExperimentalFeatures; track feature.name; let isLast = $last) { + +
{{ feature.name }}
+ @if (feature.description != null && feature.description !== "") { +
{{ feature.description }}
+ } +
+ } @empty { +

No experimental features are currently available.

+ } +
+
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.scss new file mode 100644 index 00000000000..68ca39a1196 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.scss @@ -0,0 +1,40 @@ +.mat-mdc-dialog-title { + display: flex; + align-items: center; + justify-content: space-between; +} + +.title-left { + display: flex; + align-items: center; + column-gap: 0.5em; +} + +.mat-mdc-dialog-content { + width: 40em; + max-width: 100%; +} + +.feature-checkbox { + padding-block: 0.75em; + width: 100%; + white-space: normal; + + ::ng-deep .mdc-form-field { + align-items: flex-start; + } + + ::ng-deep .mdc-label { + display: block; + padding-top: 10px; + } +} + +.feature-name { + font-size: 1.1em; + font-weight: 500; +} + +.feature-description { + margin-top: 0.25em; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.spec.ts new file mode 100644 index 00000000000..3eb34c7a5c7 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.spec.ts @@ -0,0 +1,142 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { DebugElement } from '@angular/core'; +import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; +import { By } from '@angular/platform-browser'; +import { mock, when } from 'ts-mockito'; +import { ChildViewContainerComponent, configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; +import { FeatureFlag } from '../feature-flags/feature-flag.service'; +import { ExperimentalFeaturesDialogComponent } from './experimental-features-dialog.component'; +import { ExperimentalFeature, ExperimentalFeaturesService } from './experimental-features.service'; + +const mockedExperimentalFeaturesService = mock(ExperimentalFeaturesService); + +describe('ExperimentalFeaturesDialogComponent', () => { + configureTestingModule(() => ({ + imports: [ExperimentalFeaturesDialogComponent, getTestTranslocoModule()], + providers: [{ provide: ExperimentalFeaturesService, useMock: mockedExperimentalFeaturesService }] + })); + + let overlayContainer: OverlayContainer; + + beforeEach(() => { + overlayContainer = TestBed.inject(OverlayContainer); + }); + + afterEach(() => { + // Prevents 'Error: Test did not clean up its overlay container content.' + overlayContainer.ngOnDestroy(); + }); + + it('Shows available experimental features and whether they are editable', fakeAsync(() => { + const env = new TestEnvironment(); + + expect(env.getFeatureNameText(0)).toBe('Feature A'); + expect(env.getMatCheckbox(0).disabled).toBeFalsy(); + expect(env.getMatCheckbox(0).checked).toBeTruthy(); + + expect(env.getFeatureNameText(1)).toBe('Feature B'); + expect(env.getMatCheckbox(1).disabled).toBeTruthy(); + expect(env.getMatCheckbox(1).checked).toBeFalsy(); + + expect(env.getDescriptionText(0)).toBe('Description A'); + expect(env.getDescriptionText(1)).toBe('Description B'); + })); + + it('Updates a feature flag when its checkbox is checked or unchecked', fakeAsync(() => { + const env = new TestEnvironment(); + expect(env.getFeatureEnabled(0)).toBeTruthy(); + + // SUT + env.clickMatCheckbox(0); + env.wait(); + + expect(env.getFeatureEnabled(0)).toBeFalsy(); + })); +}); + +class TestEnvironment { + private readonly fixture: ComponentFixture; + private readonly features: ExperimentalFeature[]; + + constructor() { + this.features = [ + { + name: 'Feature A', + description: 'Description A', + available: () => true, + featureFlag: { + key: 'FEATURE_A', + description: 'Feature A', + position: 0, + readonly: false, + enabled: true + } as FeatureFlag + }, + { + name: 'Feature B', + description: 'Description B', + available: () => true, + featureFlag: { + key: 'FEATURE_B', + description: 'Feature B', + position: 1, + readonly: true, + enabled: false + } as FeatureFlag + } + ]; + + when(mockedExperimentalFeaturesService.availableExperimentalFeatures).thenReturn(this.features); + + this.fixture = TestBed.createComponent(ChildViewContainerComponent); + const config: MatDialogConfig = { + viewContainerRef: this.fixture.componentInstance.childViewContainer + }; + + // SUT + TestBed.inject(MatDialog).open(ExperimentalFeaturesDialogComponent, config); + + this.wait(); + } + + get checkboxes(): DebugElement[] { + return this.fixture.debugElement.queryAll(By.directive(MatCheckbox)); + } + + get featureNames(): DebugElement[] { + return this.fixture.debugElement.queryAll(By.css('.feature-name')); + } + + get descriptions(): DebugElement[] { + return this.fixture.debugElement.queryAll(By.css('.feature-description')); + } + + getFeatureNameText(i: number): string { + return this.featureNames[i].nativeElement.textContent?.trim(); + } + + getMatCheckbox(i: number): MatCheckbox { + return this.checkboxes[i].injector.get(MatCheckbox); + } + + getDescriptionText(i: number): string { + return this.descriptions[i].nativeElement.textContent?.trim(); + } + + getFeatureEnabled(i: number): boolean { + return this.features[i].featureFlag.enabled; + } + + clickMatCheckbox(i: number): void { + const input: HTMLInputElement | null = this.checkboxes[i].nativeElement.querySelector('input'); + input!.click(); + } + + wait(): void { + tick(); + this.fixture.detectChanges(); + flush(); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.ts new file mode 100644 index 00000000000..88522a81226 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features-dialog.component.ts @@ -0,0 +1,34 @@ +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatIconButton } from '@angular/material/button'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatDialogClose, MatDialogContent, MatDialogTitle } from '@angular/material/dialog'; +import { MatDivider } from '@angular/material/divider'; +import { MatIcon } from '@angular/material/icon'; +import { TranslocoModule } from '@ngneat/transloco'; +import { ExperimentalFeature, ExperimentalFeaturesService } from './experimental-features.service'; + +@Component({ + templateUrl: './experimental-features-dialog.component.html', + styleUrls: ['./experimental-features-dialog.component.scss'], + imports: [ + TranslocoModule, + MatDialogTitle, + MatIcon, + MatIconButton, + CdkScrollable, + MatDialogContent, + MatDialogClose, + MatCheckbox, + MatDivider, + FormsModule + ] +}) +export class ExperimentalFeaturesDialogComponent { + constructor(readonly experimentalFeatures: ExperimentalFeaturesService) {} + + get availableExperimentalFeatures(): ExperimentalFeature[] { + return this.experimentalFeatures.availableExperimentalFeatures; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features.service.ts new file mode 100644 index 00000000000..c7b77548738 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/experimental-features/experimental-features.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@angular/core'; +import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; +import { FeatureFlag, FeatureFlagService } from '../feature-flags/feature-flag.service'; +import { SFUserProjectsService } from '../user-projects.service'; +import { UserService } from '../user.service'; + +/** Wraps a feature flag as an experimental feature, giving it a name, description, and availability check */ +export interface ExperimentalFeature { + name: string; + description: string; + available: () => boolean; + featureFlag: FeatureFlag; +} + +@Injectable({ providedIn: 'root' }) +export class ExperimentalFeaturesService { + constructor( + private readonly featureFlagService: FeatureFlagService, + private readonly userService: UserService, + private readonly userProjectsService: SFUserProjectsService + ) {} + + public experimentalFeatures: ExperimentalFeature[] = [ + { + name: 'Enable chapter-level drafting & training', + description: + 'Choose which chapters to generate, so that your existing translations of other chapters in the same book can be used to train the language model and improve draft quality.', + available: () => + this.doesUserHaveAnyOfRolesOnAnyProject([ + SFProjectRole.ParatextAdministrator, + SFProjectRole.ParatextTranslator + ]), + featureFlag: this.featureFlagService.partialBookDrafting + } + ]; + + public get availableExperimentalFeatures(): ExperimentalFeature[] { + return this.experimentalFeatures.filter(feature => feature.available()); + } + + public get showExperimentalFeaturesInMenu(): boolean { + return this.availableExperimentalFeatures.length > 0; + } + + /** Helper method for experimental features, since many of them will be limited to particular roles */ + private doesUserHaveAnyOfRolesOnAnyProject(roles: SFProjectRole[]): boolean { + const projectDocs = this.userProjectsService.projectDocs ?? []; + return projectDocs.some(projectDoc => { + const userRoleOnProject = projectDoc.data?.userRoles[this.userService.currentUserId]; + return roles.some(role => role === userRoleOnProject); + }); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/feature-flags/feature-flag.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/feature-flags/feature-flag.service.ts index 6c55420d610..7ebc20f3877 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/feature-flags/feature-flag.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/feature-flags/feature-flag.service.ts @@ -365,6 +365,13 @@ export class FeatureFlagService { new StaticFeatureFlagStore(true) ); + readonly partialBookDrafting: FeatureFlag = new FeatureFlagFromStorage( + 'PartialBookDrafting', + 'Partial book drafting', + 20, + this.featureFlagStore + ); + get featureFlags(): FeatureFlag[] { return Object.values(this).filter(value => value instanceof FeatureFlagFromStorage); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts index 8036dde8473..91dd65bf2df 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts @@ -124,6 +124,7 @@ export class I18nService { } private currentLocale$ = new BehaviorSubject(defaultLocale); + private conjunctionListFormatter: Intl.ListFormat | undefined; constructor( locationService: LocationService, @@ -204,6 +205,7 @@ export class I18nService { } this.currentLocale$.next(locale); + this.conjunctionListFormatter = undefined; this.transloco.setActiveLang(locale.canonicalTag); const date = new Date(); date.setFullYear(date.getFullYear() + 1); @@ -348,7 +350,16 @@ export class I18nService { } enumerateList(list: string[]): string { - return new (Intl as any).ListFormat(this.localeCode, { style: 'long', type: 'conjunction' }).format(list); + return this.getConjunctionListFormatter().format(list); + } + + enumerateListParts(list: string[]): { type: 'element' | 'literal'; value: string }[] { + return this.getConjunctionListFormatter().formatToParts(list); + } + + private getConjunctionListFormatter(): Intl.ListFormat { + this.conjunctionListFormatter ??= new Intl.ListFormat(this.localeCode, { style: 'long', type: 'conjunction' }); + return this.conjunctionListFormatter; } translateTextAroundTemplateTags(key: I18nKey, params: object = {}): TextAroundTemplate | undefined { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.spec.ts new file mode 100644 index 00000000000..e0bbaffa6f8 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.spec.ts @@ -0,0 +1,53 @@ +import { difference, intersection, union } from './set-util'; + +describe('set-util', () => { + describe('intersection', () => { + it('returns elements present in both sets', () => { + expect([...intersection(new Set([1, 2, 3]), new Set([2, 3, 4]))]).toEqual([2, 3]); + }); + + it('returns an empty set when there is no overlap', () => { + expect(intersection(new Set([1, 2]), new Set([3, 4])).size).toBe(0); + }); + + it('does not mutate the inputs', () => { + const a = new Set([1, 2, 3]); + const b = new Set([2, 3, 4]); + intersection(a, b); + expect([...a]).toEqual([1, 2, 3]); + expect([...b]).toEqual([2, 3, 4]); + }); + }); + + describe('union', () => { + it('returns elements present in either set, without duplicates', () => { + expect([...union(new Set([1, 2]), new Set([2, 3]))]).toEqual([1, 2, 3]); + }); + + it('does not mutate the inputs', () => { + const a = new Set([1, 2]); + const b = new Set([2, 3]); + union(a, b); + expect([...a]).toEqual([1, 2]); + expect([...b]).toEqual([2, 3]); + }); + }); + + describe('difference', () => { + it('returns elements in the first set but not the second', () => { + expect([...difference(new Set([1, 2, 3]), new Set([2, 3, 4]))]).toEqual([1]); + }); + + it('returns a copy of the first set when there is no overlap', () => { + expect([...difference(new Set([1, 2]), new Set([3, 4]))]).toEqual([1, 2]); + }); + + it('does not mutate the inputs', () => { + const a = new Set([1, 2, 3]); + const b = new Set([2, 3]); + difference(a, b); + expect([...a]).toEqual([1, 2, 3]); + expect([...b]).toEqual([2, 3]); + }); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.ts new file mode 100644 index 00000000000..f35015edc92 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/util/set-util.ts @@ -0,0 +1,34 @@ +/** + * Set helpers that mirror the ES2024 `Set.prototype` composition methods (intersection/union/difference), implemented + * without relying on those methods so the app keeps working on browsers that don't yet support them. Each returns a new + * Set and leaves the inputs unmodified. + */ + +/** Returns a new Set containing the elements present in both `a` and `b`. */ +export function intersection(a: ReadonlySet, b: ReadonlySet): Set { + // Iterate the smaller set for fewer membership checks; the result is the same either way. + const [smaller, larger] = a.size <= b.size ? [a, b] : [b, a]; + const result = new Set(); + for (const value of smaller) { + if (larger.has(value)) result.add(value); + } + return result; +} + +/** Returns a new Set containing the elements present in either `a` or `b`. */ +export function union(a: ReadonlySet, b: ReadonlySet): Set { + const result = new Set(a); + for (const value of b) { + result.add(value); + } + return result; +} + +/** Returns a new Set containing the elements present in `a` but not in `b`. */ +export function difference(a: ReadonlySet, b: ReadonlySet): Set { + const result = new Set(a); + for (const value of b) { + result.delete(value); + } + return result; +} diff --git a/src/SIL.XForge.Scripture/Models/BookProgress.cs b/src/SIL.XForge.Scripture/Models/BookProgress.cs index 34e6bda2adc..5823a03c77d 100644 --- a/src/SIL.XForge.Scripture/Models/BookProgress.cs +++ b/src/SIL.XForge.Scripture/Models/BookProgress.cs @@ -19,4 +19,24 @@ public class BookProgress /// The number of blank verse segments in this book. /// public int BlankVerseSegments { get; set; } + + /// + /// The progress data for each chapter in this book. + /// + public ChapterProgress[] Chapters { get; set; } = []; +} + +public class ChapterProgress +{ + public int ChapterNumber { get; set; } + + /// + /// The total number of verse segments in this chapter. + /// + public int VerseSegments { get; set; } + + /// + /// The number of blank verse segments in this chapter. + /// + public int BlankVerseSegments { get; set; } } diff --git a/src/SIL.XForge.Scripture/Models/BuildConfig.cs b/src/SIL.XForge.Scripture/Models/BuildConfig.cs index a4d30451cce..a856a626cfd 100644 --- a/src/SIL.XForge.Scripture/Models/BuildConfig.cs +++ b/src/SIL.XForge.Scripture/Models/BuildConfig.cs @@ -20,6 +20,17 @@ public class BuildConfig /// The DataIds of the files to use as for training. public HashSet TrainingDataFiles { get; set; } = []; + /// + /// Gets or sets the DataIds of all the training data files that were available to choose from for this build. + /// + /// The DataIds of the files that were offered to the user, of which is the + /// selected subset. null if the client did not report the available files. + /// + /// When provided, this is persisted to so that a later + /// build can tell newly added files apart from files the user deliberately deselected. + /// + public HashSet? AvailableTrainingDataFiles { get; set; } + /// /// Gets or sets the per-project books and chapters to use for training. /// diff --git a/src/SIL.XForge.Scripture/Models/DraftConfig.cs b/src/SIL.XForge.Scripture/Models/DraftConfig.cs index 9fe143f6918..e1b60c2006a 100644 --- a/src/SIL.XForge.Scripture/Models/DraftConfig.cs +++ b/src/SIL.XForge.Scripture/Models/DraftConfig.cs @@ -8,6 +8,13 @@ public class DraftConfig public IList TrainingSources { get; set; } = []; public IList LastSelectedTrainingScriptureRanges { get; set; } = []; public IList LastSelectedTrainingDataFiles { get; set; } = []; + + /// + /// The training data files that were available to choose from at the time of the last build. Used to distinguish + /// newly added files (which default to selected) from files the user deliberately deselected. null for + /// builds made before this was recorded (an empty list means a build recorded that zero files were available). + /// + public IList? LastAvailableTrainingDataFiles { get; set; } public IList LastSelectedTranslationScriptureRanges { get; set; } = []; public bool? FastTraining { get; set; } public bool? UseEcho { get; set; } diff --git a/src/SIL.XForge.Scripture/Services/BuildConfigJsonConverter.cs b/src/SIL.XForge.Scripture/Services/BuildConfigJsonConverter.cs index f1299c95455..1895c6f8f05 100644 --- a/src/SIL.XForge.Scripture/Services/BuildConfigJsonConverter.cs +++ b/src/SIL.XForge.Scripture/Services/BuildConfigJsonConverter.cs @@ -31,6 +31,12 @@ public override void WriteJson(JsonWriter writer, BuildConfig? value, JsonSerial serializer.Serialize(writer, value.TrainingDataFiles); } + if (value.AvailableTrainingDataFiles is not null) + { + writer.WritePropertyName(nameof(value.AvailableTrainingDataFiles)); + serializer.Serialize(writer, value.AvailableTrainingDataFiles); + } + if (value.TrainingScriptureRanges.Count > 0) { writer.WritePropertyName(nameof(value.TrainingScriptureRanges)); diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index be7a58dfd7b..c32567ce6be 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -2802,6 +2802,16 @@ await projectDoc.SubmitJson0OpAsync(op => [.. buildConfig.TrainingDataFiles], _listStringComparer ); + // Only record the available files when the client reported them, so that a null value continues to mean + // "no record" (i.e. a build made before this was tracked) rather than "zero files were available". + // No equality comparer here: the field starts null, and _listStringComparer cannot compare against null. + if (buildConfig.AvailableTrainingDataFiles is not null) + { + op.Set?>( + p => p.TranslateConfig.DraftConfig.LastAvailableTrainingDataFiles, + [.. buildConfig.AvailableTrainingDataFiles] + ); + } op.Set( p => p.TranslateConfig.DraftConfig.LastSelectedTrainingScriptureRanges, [.. buildConfig.TrainingScriptureRanges], diff --git a/src/SIL.XForge.Scripture/Services/MachineProjectService.cs b/src/SIL.XForge.Scripture/Services/MachineProjectService.cs index 89b28bd5fa7..bf9a3884a3c 100644 --- a/src/SIL.XForge.Scripture/Services/MachineProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineProjectService.cs @@ -1160,6 +1160,16 @@ IList corporaSyncInfo options["max_steps"] = 20; } + string? targetProjectId = corporaSyncInfo + .FirstOrDefault(s => !s.IsSource && s.ParallelCorpusId == servalData.ParallelCorpusIdForTrainOn) + ?.ProjectId; + + // Only present when submitted by the new partial book drafting component, which includes the target + // project in TrainingScriptureRanges so that drafted chapters can be excluded from training. + string? explicitTargetRange = buildConfig + .TrainingScriptureRanges.FirstOrDefault(t => t.ProjectId == targetProjectId) + ?.ScriptureRange; + // Create the build configuration var translationBuildConfig = new TranslationBuildConfig { @@ -1211,50 +1221,43 @@ .. corporaSyncInfo .Select(s => new ParallelCorpusFilterConfig { CorpusId = s.CorpusId, - // NOTE: The scripture range will be set to match the matching source filter's scripture range below + ScriptureRange = explicitTargetRange, }), ], }, ], }; - // Set the training target filter scripture ranges to the same as the source filter scripture ranges - foreach (TrainingCorpusConfig trainingCorpusConfig in translationBuildConfig.TrainOn) + // The legacy draft UI does not specify an explicit target training range, so it is derived here from the + // union of the source training ranges. The new draft component always specifies a target training range, + // though it may be an empty string, which is NOT the same as not specifying one at all (an empty range means + // "train on no target books", so it must skip this fallback; hence `== null`, not IsNullOrEmpty). Once the + // legacy stepper is gone every build will specify a target range, so this fallback can be removed and the + // presence of a target range enforced instead. + if (explicitTargetRange == null) { - for (int j = 0; j < trainingCorpusConfig.SourceFilters?.Count; j++) + foreach (TrainingCorpusConfig trainingCorpusConfig in translationBuildConfig.TrainOn) { - if (trainingCorpusConfig.TargetFilters is not null) + if (trainingCorpusConfig.TargetFilters is null || trainingCorpusConfig.SourceFilters is null) + continue; + + for (int j = 0; j < trainingCorpusConfig.SourceFilters.Count; j++) { - if ( - trainingCorpusConfig.TargetFilters.Count > j - && trainingCorpusConfig.TargetFilters[j] is not null - ) + string sourceRange = trainingCorpusConfig.SourceFilters[j].ScriptureRange ?? string.Empty; + + if (j < trainingCorpusConfig.TargetFilters.Count) { - // Set the scripture range for the matching target filter - trainingCorpusConfig.TargetFilters[j].ScriptureRange = - trainingCorpusConfig.SourceFilters[j].ScriptureRange ?? string.Empty; + trainingCorpusConfig.TargetFilters[j].ScriptureRange = sourceRange; } - else if (trainingCorpusConfig.TargetFilters[0] is not null) + else { - // There is no matching target filter, so update the first target filter. - // Merge the two scripture ranges by getting the distinct book names. - // This will also preserve any chapter ranges that are specified. - trainingCorpusConfig.TargetFilters[0].ScriptureRange = string.Join( - ';', - string.Join( - ';', - trainingCorpusConfig.TargetFilters[0].ScriptureRange ?? string.Empty, - trainingCorpusConfig.SourceFilters[j].ScriptureRange ?? string.Empty - ) - .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct() - ); - - // Ensure that the scripture range is empty if it is null, so that no books will be trained on - if (string.IsNullOrWhiteSpace(trainingCorpusConfig.TargetFilters[0].ScriptureRange)) - { - trainingCorpusConfig.TargetFilters[0].ScriptureRange = string.Empty; - } + // There are more source filters than target filters; accumulate the overflow + // source ranges into TargetFilters[0] by merging and deduplicating. + string existingRange = trainingCorpusConfig.TargetFilters[0].ScriptureRange ?? string.Empty; + IEnumerable mergedEntries = $"{existingRange};{sourceRange}" + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(); + trainingCorpusConfig.TargetFilters[0].ScriptureRange = string.Join(';', mergedEntries); } } } diff --git a/src/SIL.XForge.Scripture/Services/SFProjectService.cs b/src/SIL.XForge.Scripture/Services/SFProjectService.cs index 7b29781d863..9eb57276d1f 100644 --- a/src/SIL.XForge.Scripture/Services/SFProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/SFProjectService.cs @@ -2321,6 +2321,24 @@ public async Task GetProjectProgressAsync(string curUserId, stri { "_id", "$book" }, { "verseSegments", new BsonDocument("$sum", "$verseSegments") }, { "blankVerseSegments", new BsonDocument("$sum", "$blankVerseSegments") }, + { + "chapters", + new BsonDocument( + "$push", + new BsonDocument + { + { + "chapterNumber", + new BsonDocument( + "$arrayElemAt", + new BsonArray { new BsonDocument("$split", new BsonArray { "$_id", ":" }), 2 } + ) + }, + { "verseSegments", "$verseSegments" }, + { "blankVerseSegments", "$blankVerseSegments" }, + } + ) + }, } ) .ToListAsync(); @@ -2331,6 +2349,16 @@ public async Task GetProjectProgressAsync(string curUserId, stri BookId = doc["_id"].AsString, VerseSegments = doc["verseSegments"].AsInt32, BlankVerseSegments = doc["blankVerseSegments"].AsInt32, + Chapters = + [ + .. doc["chapters"] + .AsBsonArray.Select(chapterDoc => new ChapterProgress + { + ChapterNumber = int.Parse(chapterDoc["chapterNumber"].AsString), + VerseSegments = chapterDoc["verseSegments"].AsInt32, + BlankVerseSegments = chapterDoc["blankVerseSegments"].AsInt32, + }), + ], }) .ToArray(); diff --git a/test/SIL.XForge.Scripture.Tests/Services/BuildConfigJsonConverterTests.cs b/test/SIL.XForge.Scripture.Tests/Services/BuildConfigJsonConverterTests.cs index 5884aed237e..1c985750705 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/BuildConfigJsonConverterTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/BuildConfigJsonConverterTests.cs @@ -80,6 +80,37 @@ public void WriteJson_Serializes_BuildConfig_TrainingDataFiles() writer.Received().WriteEndObject(); } + [Test] + public void WriteJson_Serializes_BuildConfig_AvailableTrainingDataFiles() + { + var converter = new BuildConfigJsonConverter(); + var writer = Substitute.For(); + var serializer = Substitute.For(); + var buildConfig = new BuildConfig { AvailableTrainingDataFiles = [Data01, Data02] }; + + // SUT + converter.WriteJson(writer, buildConfig, serializer); + + writer.Received().WriteStartObject(); + writer.Received().WritePropertyName(nameof(buildConfig.AvailableTrainingDataFiles)); + serializer.Received().Serialize(writer, buildConfig.AvailableTrainingDataFiles); + writer.Received().WriteEndObject(); + } + + [Test] + public void WriteJson_OmitsAvailableTrainingDataFiles_WhenNull() + { + var converter = new BuildConfigJsonConverter(); + var writer = Substitute.For(); + var serializer = Substitute.For(); + var buildConfig = new BuildConfig { ProjectId = Project01 }; + + // SUT + converter.WriteJson(writer, buildConfig, serializer); + + writer.DidNotReceive().WritePropertyName(nameof(buildConfig.AvailableTrainingDataFiles)); + } + [Test] public void WriteJson_Serializes_BuildConfig_TrainingScriptureRanges() { @@ -230,6 +261,49 @@ public void ReadJson_Deserializes_JSON_Object_TrainingDataFiles() Assert.AreEqual(Project01, buildConfig.ProjectId); } + [Test] + public void ReadJson_Deserializes_JSON_Object_AvailableTrainingDataFiles() + { + var converter = new BuildConfigJsonConverter(); + const string jsonString = $$""" + { + "{{nameof(BuildConfig.ProjectId)}}":"{{Project01}}", + "{{nameof(BuildConfig.AvailableTrainingDataFiles)}}":["{{Data01}}","{{Data02}}"] + } + """; + using var stringReader = new StringReader(jsonString); + using var reader = new JsonTextReader(stringReader); + var serializer = new JsonSerializer(); + + // SUT + var buildConfig = converter.ReadJson(reader, typeof(BuildConfig), null, false, serializer); + + Assert.IsNotNull(buildConfig); + Assert.IsNotNull(buildConfig!.AvailableTrainingDataFiles); + CollectionAssert.AreEqual(new List { Data01, Data02 }, buildConfig.AvailableTrainingDataFiles!); + Assert.AreEqual(Project01, buildConfig.ProjectId); + } + + [Test] + public void ReadJson_LeavesAvailableTrainingDataFilesNull_WhenAbsent() + { + var converter = new BuildConfigJsonConverter(); + const string jsonString = $$""" + { + "{{nameof(BuildConfig.ProjectId)}}":"{{Project01}}" + } + """; + using var stringReader = new StringReader(jsonString); + using var reader = new JsonTextReader(stringReader); + var serializer = new JsonSerializer(); + + // SUT + var buildConfig = converter.ReadJson(reader, typeof(BuildConfig), null, false, serializer); + + Assert.IsNotNull(buildConfig); + Assert.IsNull(buildConfig!.AvailableTrainingDataFiles); + } + [Test] public void ReadJson_Deserializes_JSON_Object_TrainingScriptureRanges() { diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index 6e43f970f6d..30f4aa6fb2b 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -4662,6 +4662,32 @@ await env.Service.StartPreTranslationBuildAsync( Assert.IsEmpty(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastSelectedTrainingScriptureRanges); Assert.IsEmpty(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastSelectedTrainingDataFiles); Assert.IsEmpty(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastSelectedTranslationScriptureRanges); + // The client did not report the available files, so the record stays null (rather than an empty list) + Assert.IsNull(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastAvailableTrainingDataFiles); + } + + [Test] + public async Task StartPreTranslationBuildAsync_RecordsAvailableTrainingDataFilesWhenReported() + { + // Set up test environment + var env = new TestEnvironment(); + const string data02 = "data02"; + + // SUT + await env.Service.StartPreTranslationBuildAsync( + User01, + new BuildConfig + { + ProjectId = Project01, + TrainingDataFiles = { Data01 }, + AvailableTrainingDataFiles = [Data01, data02], + }, + CancellationToken.None + ); + + DraftConfig draftConfig = env.Projects.Get(Project01).TranslateConfig.DraftConfig; + Assert.AreEqual(new[] { Data01 }, draftConfig.LastSelectedTrainingDataFiles); + Assert.AreEqual(new[] { Data01, data02 }, draftConfig.LastAvailableTrainingDataFiles); } [Test] diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs index f26c7c53634..83ad38b6625 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs @@ -2275,6 +2275,69 @@ public void GetTranslationBuildConfig_TranslationScriptureRangesAndTrainingScrip ); } + [Test] + public void GetTranslationBuildConfig_UsesExplicitTargetTrainingScriptureRange() + { + // Set up test environment + var env = new TestEnvironment(); + var servalData = new ServalData + { + ParallelCorpusIdForPreTranslate = ParallelCorpus01, + ParallelCorpusIdForTrainOn = ParallelCorpus02, + }; + const string project03ScriptureRange = "LUK;JHN"; + const string project04ScriptureRange = "ACT;ROM"; + const string targetScriptureRange = "GEN;EXO"; + var buildConfig = new BuildConfig + { + TrainingScriptureRanges = + [ + new ProjectScriptureRange { ProjectId = Project03, ScriptureRange = project03ScriptureRange }, + new ProjectScriptureRange { ProjectId = Project04, ScriptureRange = project04ScriptureRange }, + new ProjectScriptureRange { ProjectId = Project02, ScriptureRange = targetScriptureRange }, + ], + }; + List corporaSyncInfo = + [ + new ServalCorpusSyncInfo + { + CorpusId = Corpus03, + IsSource = true, + ParallelCorpusId = ParallelCorpus02, + ProjectId = Project03, + }, + new ServalCorpusSyncInfo + { + CorpusId = Corpus04, + IsSource = true, + ParallelCorpusId = ParallelCorpus02, + ProjectId = Project04, + }, + new ServalCorpusSyncInfo + { + CorpusId = Corpus05, + IsSource = false, + ParallelCorpusId = ParallelCorpus02, + ProjectId = Project02, + }, + ]; + + // SUT + TranslationBuildConfig actual = env.Service.GetTranslationBuildConfig( + servalData, + servalConfig: null, + buildConfig, + corporaSyncInfo + ); + Assert.AreEqual( + targetScriptureRange, + actual + .TrainOn!.Single(c => c.ParallelCorpusId == ParallelCorpus02) + .TargetFilters!.Single(f => f.CorpusId == Corpus05) + .ScriptureRange + ); + } + [TestCase(false, false, MachineProjectService.SmtTransfer)] [TestCase(false, true, MachineProjectService.SmtTransfer)] [TestCase(true, false, MachineProjectService.Nmt)] From d41d8e843662dba08fcfb71048ff11946f2e1d59 Mon Sep 17 00:00:00 2001 From: Nathaniel Paulus Date: Thu, 25 Jun 2026 15:11:23 -0400 Subject: [PATCH 02/10] Code review: harden book-multi-select against changing project IDs --- .../book-multi-select/book-multi-select.component.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts index 0afcc560f3a..e370e312d98 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts @@ -102,9 +102,13 @@ export class BookMultiSelectComponent implements OnChanges { this.loaded = false; return; } - if (this.projectId !== this.loadedProgressProjectId) { - this.cachedProgress = await this.progressService.getProgress(this.projectId, { maxStalenessMs: 30_000 }); - this.loadedProgressProjectId = this.projectId; + const projectId = this.projectId; + if (projectId !== this.loadedProgressProjectId) { + const fetchedProgress = await this.progressService.getProgress(projectId, { maxStalenessMs: 30_000 }); + // Inputs may have changed while the fetch was in flight; drop stale results to avoid out-of-order writes. + if (projectId !== this.projectId) return; + this.cachedProgress = fetchedProgress; + this.loadedProgressProjectId = projectId; } progress = this.cachedProgress; } From e1ffde9ecee57e4566fc884d53324baefe247976 Mon Sep 17 00:00:00 2001 From: Nathaniel Paulus Date: Thu, 25 Jun 2026 17:24:37 -0400 Subject: [PATCH 03/10] Fix from testing: improve parallelization of loading draft component --- .../new-draft/new-draft.component.ts | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts index 5aeb8d16ecd..a329bbb4875 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.ts @@ -145,6 +145,11 @@ export class NewDraftComponent { } async init(): Promise { + // Fetching the Paratext project list (for pending-update detection) doesn't depend on the project doc, so start it + // now to overlap with the init work below. The fetch is best-effort: offline (or any failure) resolves to + // undefined and detection is simply skipped. + const projectsPromise = this.fetchProjectsForPendingUpdates(); + const [, status] = await Promise.all([ this.userService.getCurrentUser().then(doc => (this.currentUserDoc = doc)), firstValueFrom(this.logicHandler.status$.pipe(filter(status => status === 'input' || status === 'abort'))) @@ -163,11 +168,12 @@ export class NewDraftComponent { const targetTag = this.activatedProjectService.projectDoc?.data?.writingSystem.tag; const draftingSourceTag = sources?.draftingSources[0]?.writingSystem?.tag; if (targetTag != null && draftingSourceTag != null) { - this.isTrainingOptional = - (await this.nllbLanguageService.isNllbLanguageAsync(targetTag)) && - (await this.nllbLanguageService.isNllbLanguageAsync(draftingSourceTag)); + const [targetIsNllb, sourceIsNllb] = await Promise.all([ + this.nllbLanguageService.isNllbLanguageAsync(targetTag), + this.nllbLanguageService.isNllbLanguageAsync(draftingSourceTag) + ]); + this.isTrainingOptional = targetIsNllb && sourceIsNllb; } - const draftConfig = this.activatedProjectService.projectDoc?.data?.translateConfig?.draftConfig; this.sendEmailOnBuildFinished = draftConfig?.sendEmailOnBuildFinished ?? false; if (this.featureFlags.showDeveloperTools.enabled) { @@ -175,16 +181,14 @@ export class NewDraftComponent { this.useEcho = draftConfig?.useEcho ?? false; } - if (this.onlineStatusService.isOnline) { - await this.detectPendingUpdates(); - } + const projects = await projectsPromise; + if (projects != null) this.detectPendingUpdates(projects); if (this.pendingProjects.length > 0) { this.page = 'pending_updates'; } else { this.page = 'preface'; this.armSyncWatcher(); } - this.logicHandler.status$ .pipe( filter(s => s === 'abort'), @@ -272,7 +276,23 @@ export class NewDraftComponent { this.selectedTrainingDataFileIds = updated; } - private async detectPendingUpdates(): Promise { + /** + * Fetches the user's Paratext projects for pending-update detection. Detection is advisory: on failure, log and + * resolve to undefined so the caller proceeds rather than stranding the user. The catch is attached here (at the + * call site) so kicking this off eagerly can never leave a rejection unhandled on an early-return path. + */ + private fetchProjectsForPendingUpdates(): Promise { + if (!this.onlineStatusService.isOnline) return Promise.resolve(undefined); + return this.paratextService.getProjects().catch(error => { + this.errorReportingService.silentError( + 'Failed to check for pending Paratext updates before drafting', + ErrorReportingService.normalizeError(error) + ); + return undefined; + }); + } + + private detectPendingUpdates(projects: ParatextProject[]): void { const sources = this.logicHandler.sources; const projectId = this.initData!.projectId; const involvedIds = new Set([ @@ -280,19 +300,7 @@ export class NewDraftComponent { ...(sources?.draftingSources.map(s => s.projectRef) ?? []), ...(sources?.trainingSources.map(s => s.projectRef) ?? []) ]); - - let projects: ParatextProject[] | undefined; - try { - projects = await this.paratextService.getProjects(); - } catch (error) { - // Detection is advisory; if we can't reach Paratext, proceed rather than stranding the user. - this.errorReportingService.silentError( - 'Failed to check for pending Paratext updates before drafting', - ErrorReportingService.normalizeError(error) - ); - return; - } - this.pendingProjects = (projects ?? []) + this.pendingProjects = projects .filter(p => p.projectId != null && involvedIds.has(p.projectId) && p.isConnected && p.hasUpdate) .map(p => ({ projectId: p.projectId!, From 5b0f1103ad467c7879e39ef2815029828b53b375 Mon Sep 17 00:00:00 2001 From: Nathaniel Paulus Date: Thu, 25 Jun 2026 22:46:46 -0400 Subject: [PATCH 04/10] Fix book-multi-select erroring when offline Test team noted that when going to the next step while offline an error was shown due to book-multi-select fetching progress. Since progress is not critical to the functionality, I've changed it to wait until the user is online to fetch it. --- .../book-multi-select.component.html | 12 +- .../book-multi-select.component.scss | 7 - .../book-multi-select.component.spec.ts | 142 ++++++++++--- .../book-multi-select.component.ts | 195 +++++++++--------- .../draft-generation-steps.component.html | 4 +- .../draft-generation-steps.component.spec.ts | 1 + .../draft-import-wizard.component.html | 1 - .../draft-onboarding-form.component.html | 4 +- .../new-draft/new-draft.component.html | 4 +- .../src/assets/i18n/non_checking_en.json | 1 - 10 files changed, 219 insertions(+), 152 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html index afa2291cb7a..b59cc07bbdc 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.html @@ -1,10 +1,10 @@ - @if (!readonly && (projectName != null || (!basicMode && availableBooks.length > 0))) { + @if (!readonly && (projectName != null || (bulkBookSelection && availableBooks.length > 0))) {
@if (projectName != null) { {{ projectName }} } - @if (!basicMode && availableBooks.length > 0) { + @if (bulkBookSelection && availableBooks.length > 0) {
- @if (book.progress != null && basicMode === false) { + @if (book.progress != null && showProgress) { }
- @if (!loaded) { -
- - {{ t("loading_message") }} -
- } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.scss index 6751ac2faa2..e8beb9feab3 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.scss @@ -28,10 +28,3 @@ background: var(--sf-book-border-fill); } } - -.loading-message { - display: flex; - gap: 0.5em; - align-items: center; - margin-block: 1em; -} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts index 01a6758ae86..0ea51884f78 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.spec.ts @@ -1,4 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; +import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; import { anything, mock, verify, when } from 'ts-mockito'; import { I18nService } from 'xforge-common/i18n.service'; import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; @@ -12,6 +15,7 @@ const mockedI18nService = mock(I18nService); describe('BookMultiSelectComponent', () => { let component: BookMultiSelectComponent; let fixture: ComponentFixture; + let onlineStatus: TestOnlineStatusService; let mockBooks: Book[]; let mockSelectedBooks: Book[]; @@ -19,6 +23,8 @@ describe('BookMultiSelectComponent', () => { configureTestingModule(() => ({ imports: [getTestTranslocoModule()], providers: [ + provideTestOnlineStatus(), + { provide: OnlineStatusService, useClass: TestOnlineStatusService }, { provide: ProgressService, useMock: mockedProgressService }, { provide: I18nService, useMock: mockedI18nService } ] @@ -28,7 +34,14 @@ describe('BookMultiSelectComponent', () => { return { number: bookNum, selected: false }; } - beforeEach(() => { + // Progress is fetched asynchronously; wait for the pipeline to settle and re-run change detection. + async function settle(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + beforeEach(async () => { mockBooks = [book(1), book(2), book(3), book(40), book(42), book(67), book(70)]; mockSelectedBooks = [ { number: 1, selected: true }, @@ -49,22 +62,26 @@ describe('BookMultiSelectComponent', () => { fixture = TestBed.createComponent(BookMultiSelectComponent); component = fixture.componentInstance; + onlineStatus = TestBed.inject(OnlineStatusService) as TestOnlineStatusService; component.availableBooks = mockBooks; component.selectedBooks = mockSelectedBooks; component.projectId = 'test-project-id'; - fixture.detectChanges(); + // Mirror the "full" usage: fetch/show progress and offer the testament select-all checkboxes. + component.showProgress = true; + component.bulkBookSelection = true; + // Inputs are set imperatively on the root fixture, so Angular won't call ngOnChanges for us; push them ourselves. + component.ngOnChanges(); + await settle(); }); it('supports providing project name', async () => { - await component.ngOnChanges(); expect(fixture.nativeElement.querySelector('.project-name')).toBeNull(); component.projectName = 'Test Project'; - fixture.detectChanges(); + await settle(); expect(fixture.nativeElement.querySelector('.project-name')).not.toBeNull(); }); - it('should initialize book options on ngOnChanges', async () => { - await component.ngOnChanges(); + it('should initialize book options', () => { expect(component.bookOptions).toEqual([ { bookNum: 1, bookId: 'GEN', selected: true, progress: 0.0 }, { bookNum: 2, bookId: 'EXO', selected: false, progress: 0.15 }, @@ -80,7 +97,8 @@ describe('BookMultiSelectComponent', () => { for (let i = 0; i < 5; i++) { component.availableBooks = [...mockBooks]; component.selectedBooks = [...mockSelectedBooks]; - await component.ngOnChanges(); + component.ngOnChanges(); + await settle(); } verify(mockedProgressService.getProgress('test-project-id', anything())).once(); @@ -89,15 +107,70 @@ describe('BookMultiSelectComponent', () => { it('re-fetches progress when the project changes', async () => { component.projectId = 'a-different-project-id'; - await component.ngOnChanges(); + component.ngOnChanges(); + await settle(); verify(mockedProgressService.getProgress('a-different-project-id', anything())).once(); expect().nothing(); }); + it('does not fetch progress when showProgress is false', async () => { + fixture = TestBed.createComponent(BookMultiSelectComponent); + component = fixture.componentInstance; + component.availableBooks = mockBooks; + component.selectedBooks = mockSelectedBooks; + component.projectId = 'no-progress-project'; + component.showProgress = false; + component.ngOnChanges(); + await settle(); + + verify(mockedProgressService.getProgress('no-progress-project', anything())).never(); + expect(component.bookOptions.length).toBe(mockBooks.length); + expect(component.bookOptions.every(b => b.progress == null)).toBe(true); + }); + + it('does not crash or fetch when offline, and still renders the books', async () => { + onlineStatus.setIsOnline(false); + component.projectId = 'offline-project'; + component.ngOnChanges(); + await settle(); + + verify(mockedProgressService.getProgress('offline-project', anything())).never(); + expect(component.bookOptions.length).toBe(mockBooks.length); + }); + + it('fetches progress once the connection returns', async () => { + onlineStatus.setIsOnline(false); + component.projectId = 'reconnect-project'; + component.ngOnChanges(); + await settle(); + verify(mockedProgressService.getProgress('reconnect-project', anything())).never(); + + onlineStatus.setIsOnline(true); + await settle(); + verify(mockedProgressService.getProgress('reconnect-project', anything())).once(); + expect().nothing(); + }); + + it('does not re-fetch progress when the connection drops and returns after it has loaded', async () => { + // beforeEach already fetched progress for 'test-project-id' while online. + verify(mockedProgressService.getProgress('test-project-id', anything())).once(); + + onlineStatus.setIsOnline(false); + await settle(); + onlineStatus.setIsOnline(true); + await settle(); + + // Online status is only a gate for the initial fetch; toggling it must not trigger another fetch. + verify(mockedProgressService.getProgress('test-project-id', anything())).once(); + expect().nothing(); + }); + it('should not crash when texts have not yet loaded', async () => { when(mockedProgressService.getProgress(anything(), anything())).thenResolve(new ProjectProgress([])); - await component.ngOnChanges(); + component.projectId = 'empty-progress-project'; + component.ngOnChanges(); + await settle(); expect(component.bookOptions).toEqual([ { bookNum: 1, bookId: 'GEN', selected: true, progress: null }, @@ -110,73 +183,76 @@ describe('BookMultiSelectComponent', () => { ]); }); - it('can select all OT books and clear all OT books', async () => { + it('can select all OT books and clear all OT books', () => { expect(component.selectedBooks.length).toEqual(2); - await component.select('OT', true); + component.select('OT', true); expect(component.selectedBooks.length).toEqual(3); - await component.select('OT', false); + component.select('OT', false); expect(component.selectedBooks.length).toEqual(0); }); - it('can select all NT books and clear all NT books', async () => { + it('can select all NT books and clear all NT books', () => { expect(component.selectedBooks.length).toEqual(2); - await component.select('NT', true); + component.select('NT', true); expect(component.selectedBooks.length).toEqual(4); - await component.select('NT', false); + component.select('NT', false); expect(component.selectedBooks.length).toEqual(2); }); - it('can select all DC books and clear all DC books', async () => { + it('can select all DC books and clear all DC books', () => { expect(component.selectedBooks.length).toEqual(2); - await component.select('DC', true); + component.select('DC', true); expect(component.selectedBooks.length).toEqual(4); - await component.select('DC', false); + component.select('DC', false); expect(component.selectedBooks.length).toEqual(2); }); it('should show checkboxes for OT, NT, and DC as indeterminate when only some books from that category are selected', async () => { - await component.select('OT', false); + component.select('OT', false); component.selectedBooks = [{ number: 1, selected: true }]; - await component.ngOnChanges(); - fixture.detectChanges(); + component.ngOnChanges(); + await settle(); expect(component.partialOT).toBe(true); expect(component.partialNT).toBe(false); expect(component.partialDC).toBe(false); - await component.select('OT', false); + component.select('OT', false); component.selectedBooks = [{ number: 40, selected: true }]; - await component.ngOnChanges(); - fixture.detectChanges(); + component.ngOnChanges(); + await settle(); expect(component.partialOT).toBe(false); expect(component.partialNT).toBe(true); expect(component.partialDC).toBe(false); - await component.select('NT', false); + component.select('NT', false); component.selectedBooks = [{ number: 67, selected: true }]; - await component.ngOnChanges(); - fixture.detectChanges(); + component.ngOnChanges(); + await settle(); expect(component.partialOT).toBe(false); expect(component.partialOT).toBe(false); expect(component.partialDC).toBe(true); }); - it('can hide checkboxes and progress in basic mode', async () => { - await component.ngOnChanges(); - fixture.detectChanges(); + it('hides the progress bars when showProgress is false', async () => { expect(fixture.nativeElement.querySelector('.book-multi-select .border-fill')).not.toBeNull(); - expect(fixture.nativeElement.querySelector('.scope-selection')).not.toBeNull(); - component.basicMode = true; - fixture.detectChanges(); + component.showProgress = false; + await settle(); expect(fixture.nativeElement.querySelector('.book-multi-select .border-fill')).toBeNull(); + }); + + it('hides the testament checkboxes when bulkBookSelection is false', async () => { + expect(fixture.nativeElement.querySelector('.scope-selection')).not.toBeNull(); + component.bulkBookSelection = false; + await settle(); expect(fixture.nativeElement.querySelector('.scope-selection')).toBeNull(); }); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts index e370e312d98..0ef68c9b6ab 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-multi-select/book-multi-select.component.ts @@ -1,12 +1,15 @@ -import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { Component, DestroyRef, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatChipListbox, MatChipOption } from '@angular/material/chips'; -import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslocoModule } from '@ngneat/transloco'; import { Canon } from '@sillsdev/scripture'; import { cloneDeep, isEqual } from 'lodash-es'; +import { BehaviorSubject, combineLatest, from, Observable, of } from 'rxjs'; +import { catchError, distinctUntilChanged, filter, map, startWith, switchMap, take } from 'rxjs/operators'; import { L10nPercentPipe } from 'xforge-common/l10n-percent.pipe'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; +import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; import { estimatedActualBookProgress, ProgressService, ProjectProgress } from '../progress-service/progress.service'; import { Book } from './book-multi-select'; @@ -20,47 +23,33 @@ export interface BookOption { type Scope = 'OT' | 'NT' | 'DC'; +/** The inputs that, when changed, require the rendered view model to be rebuilt. */ +interface RenderInputs { + availableBooks: Book[]; + selectedBooks: Book[]; + projectId?: string; + showProgress: boolean; +} + @Component({ selector: 'app-book-multi-select', templateUrl: './book-multi-select.component.html', - imports: [ - MatCheckbox, - MatChipListbox, - MatChipOption, - MatTooltip, - MatProgressSpinner, - TranslocoModule, - L10nPercentPipe - ], + imports: [MatCheckbox, MatChipListbox, MatChipOption, MatTooltip, TranslocoModule, L10nPercentPipe], styleUrls: ['./book-multi-select.component.scss'] }) -export class BookMultiSelectComponent implements OnChanges { +export class BookMultiSelectComponent implements OnInit, OnChanges { @Input() availableBooks: Book[] = []; @Input() selectedBooks: Book[] = []; @Input() readonly: boolean = false; - /** The ID of the project to get the progress. */ + /** The ID of the project to get the progress. Required for {@link showProgress} to have any effect. */ @Input() projectId?: string; @Input() projectName?: string; - @Input() basicMode: boolean = false; + /** Whether to fetch and display per-book translation progress. Requires {@link projectId}. */ + @Input() showProgress: boolean = false; + /** Whether to show the OT/NT/DC "select whole testament" checkboxes. */ + @Input() bulkBookSelection: boolean = false; @Output() bookSelect = new EventEmitter(); - protected loaded = false; - - // Fetch progress only when projectId changes, not on every ngOnChanges. If a consumer binds inputs to getters that - // return new array references each change-detection pass, the awaited fetch would reschedule change detection on - // every pass and loop forever (browser freeze). - private cachedProgress?: ProjectProgress; - private loadedProgressProjectId?: string; - - /** Deep copy of the inputs initBookOptions last ran on, used to skip redundant rebuilds (see ngOnChanges). */ - private previousInputs?: { - availableBooks: Book[]; - selectedBooks: Book[]; - projectId?: string; - basicMode: boolean; - readonly: boolean; - }; - bookOptions: BookOption[] = []; booksOT: Book[] = []; @@ -77,69 +66,46 @@ export class BookMultiSelectComponent implements OnChanges { selectedAllNT: boolean = false; selectedAllDC: boolean = false; - constructor(private readonly progressService: ProgressService) {} - - ngOnChanges(): void { - const inputs = { - availableBooks: this.availableBooks, - selectedBooks: this.selectedBooks, - projectId: this.projectId, - basicMode: this.basicMode, - readonly: this.readonly - }; - // Compare by content: new array refs with identical content don't trigger a rebuild. Deep copy so in-place mutations are still detected. - if (isEqual(inputs, this.previousInputs)) return; - this.previousInputs = cloneDeep(inputs); - void this.initBookOptions(); + // Inputs and internal selection changes are pushed here; the ngOnInit pipeline reacts to them. + private readonly renderInputs$ = new BehaviorSubject(this.currentInputs()); + + constructor( + private readonly progressService: ProgressService, + private readonly onlineStatusService: OnlineStatusService, + private readonly destroyRef: DestroyRef + ) {} + + ngOnInit(): void { + // Compare by content, not reference: consumers may bind getters that return a new array each CD pass, which would + // otherwise rebuild (and re-fetch) every pass. + const distinctInputs$ = this.renderInputs$.pipe(distinctUntilChanged(isEqual)); + + // Progress depends only on the project, so isolate it from other input changes. + const progress$: Observable = distinctInputs$.pipe( + map(inputs => (inputs.showProgress ? inputs.projectId : undefined)), + distinctUntilChanged(), + switchMap(projectId => { + if (projectId == null) return of(undefined); + // Wait until online, then fetch once. take(1) makes online status a one-time gate, not a re-fetch trigger + // startWith renders the book list now instead of blocking on the fetch (matters when offline); a failed fetch + // falls back to no progress. + return this.onlineStatusService.onlineStatus$.pipe( + filter(isOnline => isOnline), + take(1), + switchMap(() => from(this.progressService.getProgress(projectId, { maxStalenessMs: 30_000 }))), + catchError(() => of(undefined)), + startWith(undefined) + ); + }) + ); + + combineLatest([distinctInputs$, progress$]) + .pipe(quietTakeUntilDestroyed(this.destroyRef)) + .subscribe(([inputs, progress]) => this.rebuild(inputs, progress)); } - async initBookOptions(): Promise { - // Only load progress if not in basic mode - let progress: ProjectProgress | undefined; - if (this.basicMode === false) { - // projectId may arrive asynchronously; show loading state until it arrives. - if (this.projectId == null) { - this.loaded = false; - return; - } - const projectId = this.projectId; - if (projectId !== this.loadedProgressProjectId) { - const fetchedProgress = await this.progressService.getProgress(projectId, { maxStalenessMs: 30_000 }); - // Inputs may have changed while the fetch was in flight; drop stale results to avoid out-of-order writes. - if (projectId !== this.projectId) return; - this.cachedProgress = fetchedProgress; - this.loadedProgressProjectId = projectId; - } - progress = this.cachedProgress; - } - this.loaded = true; - - const progressByBookNum = (progress?.books ?? []).map(b => ({ - bookNum: Canon.bookIdToNumber(b.bookId), - progress: estimatedActualBookProgress(b) - })); - - this.bookOptions = this.availableBooks.map((book: Book) => ({ - bookNum: book.number, - bookId: Canon.bookNumberToId(book.number), - selected: this.selectedBooks.find(b => book.number === b.number) !== undefined, - progress: progressByBookNum.find(p => p.bookNum === book.number)?.progress ?? null - })); - - this.booksOT = this.selectedBooks.filter(n => Canon.isBookOT(n.number)); - this.availableBooksOT = this.availableBooks.filter(n => Canon.isBookOT(n.number)); - this.booksNT = this.selectedBooks.filter(n => Canon.isBookNT(n.number)); - this.availableBooksNT = this.availableBooks.filter(n => Canon.isBookNT(n.number)); - this.booksDC = this.selectedBooks.filter(n => Canon.isBookDC(n.number)); - this.availableBooksDC = this.availableBooks.filter(n => Canon.isBookDC(n.number)); - - this.selectedAllOT = this.booksOT.length > 0 && this.booksOT.length === this.availableBooksOT.length; - this.selectedAllNT = this.booksNT.length > 0 && this.booksNT.length === this.availableBooksNT.length; - this.selectedAllDC = this.booksDC.length > 0 && this.booksDC.length === this.availableBooksDC.length; - - this.partialOT = !this.selectedAllOT && this.booksOT.length > 0; - this.partialNT = !this.selectedAllNT && this.booksNT.length > 0; - this.partialDC = !this.selectedAllDC && this.booksDC.length > 0; + ngOnChanges(): void { + this.renderInputs$.next(this.currentInputs()); } onChipListChange(book: BookOption): void { @@ -149,7 +115,7 @@ export class BookMultiSelectComponent implements OnChanges { .filter(n => n.selected) .map(n => ({ number: n.bookNum, selected: this.bookOptions[bookIndex].selected })); this.bookSelect.emit(this.selectedBooks.map(b => b.number)); - void this.initBookOptions(); + this.renderInputs$.next(this.currentInputs()); } isBookInScope(bookNum: number, scope: Scope): boolean { @@ -159,7 +125,7 @@ export class BookMultiSelectComponent implements OnChanges { throw new Error('Invalid scope'); } - async select(scope: Scope, value: boolean): Promise { + select(scope: Scope, value: boolean): void { if (value) { this.selectedBooks.push( ...this.availableBooks.filter( @@ -169,7 +135,7 @@ export class BookMultiSelectComponent implements OnChanges { } else { this.selectedBooks = this.selectedBooks.filter(n => !this.isBookInScope(n.number, scope)); } - await this.initBookOptions(); + this.renderInputs$.next(this.currentInputs()); this.bookSelect.emit(this.selectedBooks.map(b => b.number)); } @@ -192,4 +158,43 @@ export class BookMultiSelectComponent implements OnChanges { normalizeRatioForDisplay(ratio: number): number { return ratio > 0.99 && ratio < 1 ? 0.99 : ratio; } + + private currentInputs(): RenderInputs { + // Deep copy so distinctUntilChanged still detects a later in-place mutation of an input array. + return cloneDeep({ + availableBooks: this.availableBooks, + selectedBooks: this.selectedBooks, + projectId: this.projectId, + showProgress: this.showProgress + }); + } + + private rebuild(inputs: RenderInputs, progress: ProjectProgress | undefined): void { + const progressByBookNum = (progress?.books ?? []).map(b => ({ + bookNum: Canon.bookIdToNumber(b.bookId), + progress: estimatedActualBookProgress(b) + })); + + this.bookOptions = inputs.availableBooks.map((book: Book) => ({ + bookNum: book.number, + bookId: Canon.bookNumberToId(book.number), + selected: inputs.selectedBooks.find(b => book.number === b.number) !== undefined, + progress: progressByBookNum.find(p => p.bookNum === book.number)?.progress ?? null + })); + + this.booksOT = inputs.selectedBooks.filter(n => Canon.isBookOT(n.number)); + this.availableBooksOT = inputs.availableBooks.filter(n => Canon.isBookOT(n.number)); + this.booksNT = inputs.selectedBooks.filter(n => Canon.isBookNT(n.number)); + this.availableBooksNT = inputs.availableBooks.filter(n => Canon.isBookNT(n.number)); + this.booksDC = inputs.selectedBooks.filter(n => Canon.isBookDC(n.number)); + this.availableBooksDC = inputs.availableBooks.filter(n => Canon.isBookDC(n.number)); + + this.selectedAllOT = this.booksOT.length > 0 && this.booksOT.length === this.availableBooksOT.length; + this.selectedAllNT = this.booksNT.length > 0 && this.booksNT.length === this.availableBooksNT.length; + this.selectedAllDC = this.booksDC.length > 0 && this.booksDC.length === this.availableBooksDC.length; + + this.partialOT = !this.selectedAllOT && this.booksOT.length > 0; + this.partialNT = !this.selectedAllNT && this.booksNT.length > 0; + this.partialDC = !this.selectedAllDC && this.booksDC.length > 0; + } } 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 7091a05cdf9..df0a5e21209 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 @@ -53,7 +53,6 @@

{{ t("choose_books_to_translate_title") }}

@@ -138,6 +137,8 @@

{{ t("translated_books") }}

[selectedBooks]="selectedTrainingBooksByProj(activatedProject.projectId)" [projectName]="targetProjectName" [projectId]="activatedProject.projectId" + [showProgress]="true" + [bulkBookSelection]="true" (bookSelect)="onTranslatedBookSelect($event)" data-test-id="draft-stepper-training-books" > @@ -196,7 +197,6 @@

{{ t("reference_books") }}

} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation-steps/draft-generation-steps.component.spec.ts index 381e2457e05..a277b26ebd8 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 @@ -87,6 +87,7 @@ describe('DraftGenerationStepsComponent', () => { ]) ); when(mockOnlineStatusService.isOnline).thenReturn(true); + when(mockOnlineStatusService.onlineStatus$).thenReturn(of(true)); })); describe('ngOnInit', () => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.html index e0b30eeacd4..d3eea10ed6b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-import-wizard/draft-import-wizard.component.html @@ -194,7 +194,6 @@

{{ t("confirm_books_to_import") }}

diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html index cec35d856e4..32530a5dd1c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-signup-form/draft-onboarding-form.component.html @@ -99,7 +99,8 @@

{{ t("title") }}

[availableBooks]="projectBooks" [selectedBooks]="selectedCompletedBooks" (bookSelect)="onCompletedBooksSelect($event)" - [basicMode]="false" + [showProgress]="true" + [bulkBookSelection]="true" [projectName]="currentProjectDisplayName" [projectId]="currentProjectId" > @@ -168,7 +169,6 @@

{{ t("title") }}

[availableBooks]="allCanonicalBooks" [selectedBooks]="selectedSubmittedBooks" (bookSelect)="onSubmittedBooksSelect($event)" - [basicMode]="true" > @if (showPlannedBooksRequiredError) { {{ t("planned_books_required") }} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html index b606fab1b92..b3b2e51021b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html @@ -60,7 +60,6 @@

{{ t("draft_books.title") }}

} @@ -127,6 +126,8 @@

{{ t("training_books.title") }}

[selectedBooks]="selectedTargetTrainingBooks" [projectId]="initData.projectId" [projectName]="targetProjectDisplayName" + [showProgress]="true" + [bulkBookSelection]="true" (bookSelect)="onTargetTrainingBookSelect($event)" > } @@ -181,7 +182,6 @@

{{ t("reference_books") }}

@for (source of trainingSources; track source.projectRef) { @let availableSourceBooks = availableTrainingSourceBooksForProject(source.projectRef); Date: Thu, 25 Jun 2026 22:54:16 -0400 Subject: [PATCH 05/10] Move generate draft submit spinner to right of button --- .../draft-generation/new-draft/new-draft.component.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html index b3b2e51021b..bdc7bd33f08 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/new-draft/new-draft.component.html @@ -344,9 +344,6 @@

Developer options

@if (page === "suffix") {
- @if (submitting) { -
- } + @if (submitting) { +
+ }
} @else { } @else if (page === "draft_books") {

{{ t("draft_books.title") }}

@@ -205,7 +205,7 @@

{{ t("training_files") }}

}
} - } @else if (page === "suffix") { + } @else if (page === "summary") {

{{ t("summary.draft_title", { books: draftingBookNamesFormatted }) }}

@@ -342,7 +342,7 @@

Developer options

- @if (page === "suffix") { + @if (page === "summary") {