diff --git a/__mocks__/lucide-react.tsx b/__mocks__/lucide-react.tsx index e123b235..9842b93b 100644 --- a/__mocks__/lucide-react.tsx +++ b/__mocks__/lucide-react.tsx @@ -84,3 +84,33 @@ export function Settings(props: Readonly<{ size?: number; className?: string }>) export function Plus(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; } +/** + * Stub for the FoldVertical icon used by the row-gap merge control (join two stacked rows). + * + * @param props - SVG props forwarded from the component. + * @returns A ReactElement SVG element used as a vertical-merge icon stub in tests. + */ +export function FoldVertical(props: Readonly<{ className?: string }>): ReactElement { + return ; +} + +/** + * Stub for the FoldHorizontal icon used by the continuous-strip merge control (join two adjacent + * segments). + * + * @param props - SVG props forwarded from the component. + * @returns A ReactElement SVG element used as a horizontal-merge icon stub in tests. + */ +export function FoldHorizontal(props: Readonly<{ className?: string }>): ReactElement { + return ; +} + +/** + * Stub for the Scissors icon used by the split boundary control. + * + * @param props - SVG props forwarded from the component. + * @returns A ReactElement SVG element used as a split icon stub in tests. + */ +export function Scissors(props: Readonly<{ size?: number; className?: string }>): ReactElement { + return ; +} diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 58aecbc0..4ea89927 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -43,7 +43,10 @@ "%interlinearizer_morphemeGloss_label%": "Gloss for morpheme {form}", "%interlinearizer_tokenChip_editMorphemes%": "Edit morpheme breakdown for {token}", "%interlinearizer_tokenChip_defineMorphemes%": "Define morpheme breakdown for {token}", - "%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Cross-segment phrases are not supported. This link button is outside the current segment.", + "%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Only a free edge token of an adjacent segment can be linked across a boundary.", + "%interlinearizer_boundaryControl_merge%": "Merge segments", + "%interlinearizer_boundaryControl_split%": "Split segment here", + "%interlinearizer_boundaryControl_formerBoundary%": "Former verse boundary", "%interlinearizer_modal_create_title%": "Create Interlinear Project", "%interlinearizer_modal_create_name_label%": "Name (optional)", diff --git a/cspell.json b/cspell.json index 3046472d..650265b8 100644 --- a/cspell.json +++ b/cspell.json @@ -20,6 +20,7 @@ "BBCCCVVV", "believ", "clickability", + "cmdk", "cullable", "deconflict", "deconfliction", @@ -55,6 +56,8 @@ "recentering", "recenters", "relayout", + "rescuable", + "resegment", "resnap", "sandboxed", "scriptio", @@ -65,6 +68,7 @@ "Stylesheet", "typedefs", "unanalyzed", + "unglossed", "unhover", "unobserves", "unphrased", diff --git a/e2e-tests/README.md b/e2e-tests/README.md index db053ae1..bb55ca6a 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -1,6 +1,9 @@ # e2e-tests -End-to-end tests for the interlinearizer extension using Playwright + Electron. The suite launches a real Platform.Bible instance with the extension loaded via `--extensions` and verifies the extension starts up correctly. Currently contains one smoke test confirming the extension activates and registers its PAPI command. +End-to-end tests for the interlinearizer extension using Playwright + Electron. The suite has two tiers: + +- **Smoke tests** (`tests/smoke/`, `app.fixture`) launch a fresh Platform.Bible instance with the extension loaded via `--extensions` and verify the extension starts up correctly. +- **Feature tests** (`tests/features/`, `cdp.fixture`) attach to an already-running `npm run start:cdp` instance and exercise interlinearizer UI flows (glossing, draft persistence, project modals). **Contents:** @@ -17,3 +20,17 @@ These tests are adapted from `paranext-core`'s e2e suite with changes to support - **Extension launch helper** — `fixtures/helpers.ts` uses `launchElectronWithExtension()` instead of `launchElectronApp()`. It passes `--extensions ` to the Electron process, resolves the Electron binary from paranext-core's `node_modules`, and polls `rpc.discover` for the extension's PAPI method to confirm activation. - **Window finding** — `fixtures/app.fixture.ts` manually polls `electronApp.windows()` by URL instead of calling `electronApp.firstWindow()`, because the extension injects content into an existing window rather than being the sole owner of the renderer. - **Renderer readiness** — `global-setup.ts` adds an HTTP GET probe after the TCP port check to wait for webpack compilation to finish, rather than assuming the port being open means the bundle is ready. + +## Writing feature tests + +Feature tests run with `npm run test:e2e:cdp` against a shared, long-lived `npm run start:cdp` instance, so they must assume nothing about its state and must leave nothing behind that could poison the next run. The protocol: + +- **Import from `cdp.fixture`, never `app.fixture`.** The CDP config already serializes execution (`workers: 1`), so tests never race each other on the shared instance. +- **The instance is only ever used with the WEB project.** This is an operating assumption, not something tests verify: `ensureInterlinearizerOpenOnWeb()` trusts an existing Interlinearizer tab and only picks WEB when opening fresh. Don't point `start:cdp` at other projects. +- **Mutating tests operate on the dedicated "E2E Test Project", never on a developer's own projects.** `ensureE2eProjectActive()` opens it (creating it on first use) at the start of each mutating test. Because the draft is the single per-source working buffer, replacing it could destroy unsaved developer work — so when the draft is dirty and the active project is _not_ the e2e project, the helper first saves the draft into a new `e2e-rescued-work-` project. Rescue projects are backups, not junk: delete them manually once recovered. Dirty state left while the e2e project is active is treated as leftover test data and discarded. +- **Self-establish every precondition.** Each mutating test starts with `ensureInterlinearizerOpenOnWeb()` → `ensureE2eProjectActive()` → `navigateToScriptureRef()` (the scroll-group reference could be anywhere) → `wipeDraft()`. +- **Reset at the start, tidy at the end.** The start-of-test sequence is what guarantees correctness — it self-heals whatever a failed run left behind. Mutating tests additionally end with `ensureE2eProjectActive(page, { rescueDirtyDraft: false })` to discard their own leftovers; this is a courtesy so the next run doesn't misread test junk as rescuable developer work, not something correctness depends on. +- **Use unique per-run values** (e.g. `` `e2e-gloss-${Date.now()}` ``) for anything written into the draft, so a stale leftover can never satisfy an assertion. +- **Drive only the visible UI.** No JSON-RPC/WebSocket calls to set up or assert state (the rpc.discover readiness polls in the shared helpers are the one sanctioned exception). +- **Prefer existing accessible selectors** (roles, aria-labels like `Gloss for {word}`, ModalShell title ids) over adding new `data-testid`s to production code. +- **Mutating tests must not overwrite or delete projects, and must not create any beyond what `ensureE2eProjectActive()` creates** (the e2e project itself, plus rescue projects). The current modal coverage is a read-only cancel tour; a create/delete lifecycle test needs its own self-healing cleanup (e.g. deleting leftover `e2e-*` projects at start) before it's safe on a shared instance. diff --git a/e2e-tests/fixtures/helpers.ts b/e2e-tests/fixtures/helpers.ts index a8c604a0..314ea402 100644 --- a/e2e-tests/fixtures/helpers.ts +++ b/e2e-tests/fixtures/helpers.ts @@ -1,5 +1,12 @@ // Adapted from paranext-core/e2e-tests/fixtures/helpers.ts -import { _electron as electron, ElectronApplication, expect, Page } from '@playwright/test'; +import { + _electron as electron, + ElectronApplication, + expect, + FrameLocator, + Locator, + Page, +} from '@playwright/test'; import fs from 'fs'; import { createRequire } from 'module'; import os from 'os'; @@ -400,19 +407,35 @@ export async function waitForInterlinearizerReady(timeoutMs = 90_000): Promise { - // Focus the Scripture Editor tab (opens without a project at fresh start). - const editorTab = page.locator('.dock-tab', { hasText: 'Scripture Editor' }).first(); + // A Scripture Editor tab is titled by the project short name once a project is loaded (e.g. + // "WEB (Editable)"), and "Scripture Editor" only when no project is loaded. Escape projectName so + // a short name with regex metacharacters can't corrupt the pattern. + const escapedProjectName = escapeRegExp(projectName); + const editorTab = page + .locator('.dock-tab', { hasText: new RegExp(`^(Scripture Editor|${escapedProjectName})\\b`) }) + .first(); + const homeTab = page.locator('.dock-tab', { hasText: 'Home' }).first(); + + // Wait for the dock layout to actually mount before deciding which path to take — a fresh profile + // briefly reports zero tabs, and a non-waiting `count()` would misread that as "no editor". + await expect(editorTab.or(homeTab)).toBeVisible({ timeout: 30_000 }); + + // If the layout came up without a Scripture Editor (single-tab Home layout), open the project + // from Home so the editor (and its ≡ menu) exists before we try to focus it. + if ((await editorTab.count()) === 0) { + await homeTab.click(); + const homeFrame = page.frameLocator('iframe[title="Home"]'); + await homeFrame.locator(`tr:has-text("${projectName}") button:has-text("Open")`).click(); + } + await expect(editorTab).toBeVisible({ timeout: 15_000 }); await editorTab.click(); // The Scripture Editor renders its own toolbar inside its iframe. Click the ≡ ("Project") button. - const editorFrame = page.frameLocator('iframe[title*="Scripture Editor" i]'); + const editorFrame = page + .frameLocator(`iframe[title*="Scripture Editor" i], iframe[title^="${projectName}"]`) + .first(); await editorFrame.locator("button[aria-label='Project']").first().click(); // Click the "Open Interlinearizer for this Project" item contributed by this extension. @@ -435,16 +479,332 @@ export async function openInterlinearizerFromScriptureEditor( .first() .click(); - // The command calls papi.dialogs.selectProject (because no project is selected in a fresh start), - // which opens a floating "Open Interlinearizer" dock tab with the project list. + // When the editor has no project selected, the command calls papi.dialogs.selectProject, which + // opens a floating "Open Interlinearizer" dock tab with the project list. When the editor + // already has a project (a warm instance), the Interlinearizer tab opens directly instead. const selectProjectDialog = page.locator('.select-project-dialog'); - await expect(selectProjectDialog).toBeVisible({ timeout: 15_000 }); - const escapedProjectName = projectName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const projectNameRegex = new RegExp(`^${escapedProjectName}$`, 'i'); - await selectProjectDialog.getByRole('button', { name: projectNameRegex }).click(); + const interlinearizerTab = interlinearizerTabLocator(page); + await expect(selectProjectDialog.or(interlinearizerTab)).toBeVisible({ timeout: 15_000 }); + if (await selectProjectDialog.isVisible()) { + const projectNameRegex = new RegExp(`^${escapedProjectName}$`, 'i'); + await selectProjectDialog.getByRole('button', { name: projectNameRegex }).click(); + } // Wait for the Interlinearizer tab to appear and focus it. - const interlinearizerTab = page.locator('.dock-tab', { hasText: 'Interlinearizer' }).first(); await expect(interlinearizerTab).toBeVisible({ timeout: 15_000 }); await interlinearizerTab.click(); } + +/** + * Frame locator for the Interlinearizer WebView's iframe, where all of the extension's own UI + * (toolbar, token strips, modals) renders. + * + * @param page The Playwright `Page` for the Platform.Bible renderer window. + * @returns A `FrameLocator` scoped to the Interlinearizer WebView iframe. + */ +export function getInterlinearizerFrame(page: Page): FrameLocator { + // Anchor on titles that START with "Interlinearizer" so this never matches the project-picker + // dialog ("Open Interlinearizer"), whose title also contains the word. The real WebView title is + // "Interlinearizer" (optionally suffixed with the unsaved-changes marker), so a prefix match keeps + // the dirty-state title while excluding the "Open …" picker. + return page.frameLocator('iframe[title^="Interlinearizer" i]'); +} + +/** + * Locator for the Interlinearizer WebView's dock tab. Matches the tab whose title contains + * "Interlinearizer" while excluding the project-picker dialog's own dock tab ("Open + * Interlinearizer"), whose title also contains the word. Centralizes the exclusion so callers can't + * forget it (the `getInterlinearizerFrame` iframe uses a prefix match for the same purpose). + * + * @param page The Playwright `Page` for the Platform.Bible renderer window. + * @returns A `Locator` for the Interlinearizer WebView dock tab. + */ +function interlinearizerTabLocator(page: Page): Locator { + return page + .locator('.dock-tab', { hasText: 'Interlinearizer', hasNotText: 'Open Interlinearizer' }) + .first(); +} + +/** + * Escape a literal string so it can be embedded in a `RegExp` without its metacharacters being + * interpreted (e.g. a project short name containing `.` or `(`). + * + * @param literal The raw string to escape. + * @returns The string with all regex metacharacters backslash-escaped. + */ +function escapeRegExp(literal: string): string { + return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Ensure the Interlinearizer is open and focused, reusing an existing tab when one is present. + * Standard precondition for feature tests running against the shared CDP instance: an existing + * Interlinearizer tab is trusted to be on the WEB project (the shared instance is only ever used + * with WEB — see e2e-tests/README.md); otherwise the tab is opened fresh via the Scripture Editor + * menu flow. Resolves only once the extension's toolbar has rendered inside the iframe. + * + * @param page The Playwright `Page` for the Platform.Bible renderer window. + * @returns Resolves when the Interlinearizer tab is focused and its toolbar is interactive. + * @throws If the Interlinearizer cannot be opened or its toolbar does not render within the + * timeouts. + */ +export async function ensureInterlinearizerOpenOnWeb(page: Page): Promise { + const interlinearizerTab = interlinearizerTabLocator(page); + + // Settle the dock layout before the non-retrying isVisible() branch below. The readiness helpers + // only poll rpc.discover, not the DOM, so without this a not-yet-painted Interlinearizer tab (or + // one just closed by a prior test) would read as "absent" and send us needlessly down the full + // open-from-editor flow. Wait until either the Interlinearizer tab or some editor/Home anchor tab + // is mounted, so isVisible() reflects a settled layout. + const anchorTab = page.locator('.dock-tab', { hasText: /Scripture Editor|Home|WEB/ }).first(); + await expect(interlinearizerTab.or(anchorTab)).toBeVisible({ timeout: 30_000 }); + + if (await interlinearizerTab.isVisible()) { + await interlinearizerTab.click(); + } else { + await openInterlinearizerFromScriptureEditor(page); + } + const frame = getInterlinearizerFrame(page); + await expect(frame.locator("button[aria-label='Project']").first()).toBeVisible({ + timeout: 30_000, + }); +} + +/** + * Navigate the platform's book-chapter-verse control to the given scripture reference so tests can + * assert against known text. Opens the toolbar's reference combobox, types the reference, and + * submits it. Requires a fully-qualified reference (book, chapter, and verse — e.g. `"GEN 1:1"`); + * partial references are ambiguous and are not auto-submitted by the control. + * + * @param page The Playwright `Page` for the Platform.Bible renderer window. + * @param reference Fully-qualified scripture reference to navigate to (e.g. `"GEN 1:1"`). + * @returns Resolves when the reference popover has closed after submitting. + * @throws If the reference control does not open, or the popover does not close after submitting. + */ +export async function navigateToScriptureRef(page: Page, reference: string): Promise { + const trigger = page.locator('button[aria-label="book-chapter-trigger"]').first(); + await trigger.click(); + const input = page.locator('input[cmdk-input]').first(); + await expect(input).toBeVisible({ timeout: 5_000 }); + await input.fill(reference); + await input.press('Enter'); + // The popover closes when the reference is accepted. + await expect(input).not.toBeVisible({ timeout: 5_000 }); +} + +/** + * Open the Interlinearizer's ≡ ("Project") top menu inside its iframe and wait for the dropdown to + * appear. + * + * @param page The Playwright `Page` for the Platform.Bible renderer window. + * @returns The frame locator for the Interlinearizer iframe, for chaining menu-item clicks. + * @throws If the menu button or the opened menu does not become visible within the timeouts. + */ +export async function openInterlinearizerProjectMenu(page: Page): Promise { + const frame = getInterlinearizerFrame(page); + const projectMenuButton = frame.locator("button[aria-label='Project']").first(); + await expect(projectMenuButton).toBeVisible({ timeout: 15_000 }); + await projectMenuButton.click(); + await expect(frame.locator('[role="menu"]')).toBeVisible({ timeout: 5_000 }); + return frame; +} + +/** Name of the dedicated interlinear project the mutating feature tests operate on. */ +export const E2E_PROJECT_NAME = 'E2E Test Project'; + +/** Name prefix for projects created to rescue a developer's unsaved draft work. */ +const RESCUE_PROJECT_PREFIX = 'e2e-rescued-work'; + +/** + * Glyph the Interlinearizer appends to its dock tab title while the draft has unsaved changes. Only + * the glyph must match production's UNSAVED_TAB_MARKER in src/components/InterlinearizerLoader.tsx + * (which prefixes it with a space); this constant is used exclusively in substring checks, so the + * surrounding whitespace is deliberately not replicated. + */ +const UNSAVED_TAB_MARKER = '●'; + +/** + * Read whether the draft has unsaved changes from the Interlinearizer dock tab's title marker (the + * only place the dirty state is observable outside the WebView). + * + * @param page The Playwright `Page` for the Platform.Bible renderer window. + * @returns `true` when the tab title carries the unsaved-changes glyph. + */ +async function isDraftDirty(page: Page): Promise { + const tabText = await interlinearizerTabLocator(page).textContent(); + return (tabText ?? '').includes(UNSAVED_TAB_MARKER); +} + +/** + * Open the "Select Interlinear Project" modal from the Project menu and wait for its project list + * to finish loading (the modal's buttons are disabled while the fetch is in flight). + * + * @param page The Playwright `Page` for the Platform.Bible renderer window. + * @returns The frame locator for the Interlinearizer iframe, for chaining clicks in the modal. + * @throws If the modal does not open or its list does not finish loading within the timeouts. + */ +async function openSelectProjectModal(page: Page): Promise { + const frame = await openInterlinearizerProjectMenu(page); + await frame + .getByRole('menuitem', { name: /Select Interlinear Project/i }) + .first() + .click(); + await expect(frame.locator('#select-project-modal-title')).toBeVisible({ timeout: 10_000 }); + await expect(frame.locator('dialog').getByRole('button', { name: 'Cancel' })).toBeEnabled({ + timeout: 10_000, + }); + return frame; +} + +/** + * Preserve the current draft by saving it as a brand-new, timestamped rescue project (Project menu + * → Save As… → "Save as New Project"). Used before the e2e project replaces a dirty draft that may + * hold a developer's unsaved work, so nothing is silently discarded. Clears the draft's + * unsaved-changes state as a side effect (the rescue project becomes the active Save target). + * + * @param page The Playwright `Page` for the Platform.Bible renderer window. + * @returns Resolves when the Save As modal has closed and the unsaved marker has cleared. + * @throws If the Save As modal does not open, close, or clear the unsaved marker within the + * timeouts. + */ +async function rescueDraftToNewProject(page: Page): Promise { + const frame = await openInterlinearizerProjectMenu(page); + await frame + .getByRole('menuitem', { name: /^Save As/i }) + .first() + .click(); + + const saveAsTitle = frame.locator('#save-as-modal-title'); + await expect(saveAsTitle).toBeVisible({ timeout: 10_000 }); + await frame.locator('#save-as-name').fill(`${RESCUE_PROJECT_PREFIX}-${Date.now()}`); + await frame.getByTestId('save-as-new').click(); + await expect(saveAsTitle).not.toBeVisible({ timeout: 10_000 }); + + // The save clears the unsaved marker; wait for it so later dirty checks read the new state. + await expect(interlinearizerTabLocator(page)).not.toContainText(UNSAVED_TAB_MARKER, { + timeout: 10_000, + }); +} + +/** + * Make the dedicated e2e project ({@link E2E_PROJECT_NAME}) the active project, creating it if it + * does not exist yet, so mutating tests never touch a developer's own projects. Opening a project + * replaces the draft (the single per-source working buffer), so when the draft is dirty and the + * active project is NOT the e2e project — i.e. the unsaved work may be a developer's, not leftover + * test data — it is first rescued into a new `e2e-rescued-work-*` project instead of being + * discarded. Dirty state left while the e2e project is active is treated as leftover test data and + * discarded via the confirm dialog. + * + * Mutating tests call this at the START (with rescue on) to establish their precondition, and again + * at the END (with rescue off) to discard their own leftovers so the next run starts clean. + * + * @param page The Playwright `Page` for the Platform.Bible renderer window. + * @param opts Options object. + * @param opts.rescueDirtyDraft Whether a dirty draft not owned by the e2e project is rescued before + * being replaced (default `true`). Pass `false` only at the end of a test, where the dirty state + * is known to be the test's own leftovers. + * @returns Resolves when the e2e project is active and all modals have closed. + * @throws If the modals do not open/close or the project cannot be selected or created within the + * timeouts. + */ +export async function ensureE2eProjectActive( + page: Page, + opts: { rescueDirtyDraft?: boolean } = {}, +): Promise { + const { rescueDirtyDraft = true } = opts; + + let dirty = await isDraftDirty(page); + let frame = await openSelectProjectModal(page); + let dialog = frame.locator('dialog'); + + // Locate the E2E entry by its project-name element with an EXACT text match, then walk up to the + // enclosing entry button. Matching the whole button's accessible name doesn't work: the modal + // renders the name, an optional "Active" badge, and the analysis languages as adjacent inline + // s with no separating whitespace, so the accessible name reads "E2E Test Projecten" — + // there is no space after the name to anchor on. An exact-text match on the name element also + // avoids matching a different project whose name merely starts with E2E_PROJECT_NAME (e.g. "E2E + // Test Project 2"). Keep in sync with SelectInterlinearProjectModal's entry markup. + const activeEntry = dialog.locator('button[aria-current="true"]'); + const activeIsE2e = + (await activeEntry.count()) > 0 && + (await activeEntry.first().getByText(E2E_PROJECT_NAME, { exact: true }).count()) > 0; + + if (dirty && !activeIsE2e && rescueDirtyDraft) { + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(frame.locator('#select-project-modal-title')).not.toBeVisible({ timeout: 5_000 }); + await rescueDraftToNewProject(page); + dirty = false; + frame = await openSelectProjectModal(page); + dialog = frame.locator('dialog'); + } + + const selectTitle = frame.locator('#select-project-modal-title'); + // Rebuilt against the (possibly re-opened) dialog so it targets the current modal instance. + const e2eEntry = dialog + .locator('button', { has: frame.getByText(E2E_PROJECT_NAME, { exact: true }) }) + .first(); + if ((await e2eEntry.count()) > 0) { + await e2eEntry.click(); + if (dirty) { + // Replacing a dirty draft asks for confirmation; anything worth keeping was rescued above. + const discardConfirm = frame.getByTestId('discard-draft-confirm'); + await expect(discardConfirm).toBeVisible({ timeout: 5_000 }); + await discardConfirm.click(); + } + } else { + await dialog.getByRole('button', { name: 'Create New' }).click(); + const createTitle = frame.locator('#create-project-modal-title'); + await expect(createTitle).toBeVisible({ timeout: 5_000 }); + await frame.locator('#project-name').fill(E2E_PROJECT_NAME); + await frame.locator('dialog').getByRole('button', { name: 'Create' }).click(); + // Creating a draft over a dirty one defers behind the discard confirmation instead of closing + // the create modal (handleCreateDraft in ProjectModals.tsx), so dismiss it when dirty. + if (dirty) { + const discardConfirm = frame.getByTestId('discard-draft-confirm'); + await expect(discardConfirm).toBeVisible({ timeout: 5_000 }); + await discardConfirm.click(); + } + await expect(createTitle).not.toBeVisible({ timeout: 10_000 }); + } + await expect(selectTitle).not.toBeVisible({ timeout: 10_000 }); +} + +/** + * Wipe the entire draft's analysis via the visible UI (Project menu → Wipe… → "Entire draft" → + * Wipe). Standard reset step for mutating feature tests on the shared CDP instance: run it at the + * START of a test (never at the end) so a previously failed run self-heals instead of poisoning the + * next one. + * + * @param page The Playwright `Page` for the Platform.Bible renderer window. + * @returns Resolves when the wipe dialog has closed after confirming. + * @throws If the wipe dialog does not open or does not close after confirming. + */ +export async function wipeDraft(page: Page): Promise { + const frame = await openInterlinearizerProjectMenu(page); + await frame.getByRole('menuitem', { name: /Wipe/i }).first().click(); + + const wipeDialogTitle = frame.locator('#wipe-modal-title'); + await expect(wipeDialogTitle).toBeVisible({ timeout: 5_000 }); + await frame.getByTestId('wipe-scope-all').check(); + await frame.getByTestId('wipe-confirm').click(); + await expect(wipeDialogTitle).not.toBeVisible({ timeout: 10_000 }); +} + +/** + * Close the Interlinearizer dock tab via its close button and wait for it to disappear. Used by + * tests that verify draft persistence across a close/reopen cycle. + * + * @param page The Playwright `Page` for the Platform.Bible renderer window. + * @returns Resolves when the Interlinearizer tab is gone. + * @throws If the tab is not visible, or the tab does not close within the timeout. + */ +export async function closeInterlinearizerTab(page: Page): Promise { + const interlinearizerTab = interlinearizerTabLocator(page); + await expect(interlinearizerTab).toBeVisible({ timeout: 15_000 }); + // Dispatch the click rather than hover()+click(): the close button is only laid out on hover, and + // on small CI viewports the tab can overflow the tab strip and sit outside the viewport, where a + // real click (even with force) fails. dispatchEvent doesn't require the element to be in-viewport. + // Mirrors paranext-core's own dock-tab close helpers. + await interlinearizerTab.locator('.dock-tab-close-btn').dispatchEvent('click'); + await expect(interlinearizerTab).not.toBeVisible({ timeout: 10_000 }); +} diff --git a/e2e-tests/tests/features/draft-persistence.spec.ts b/e2e-tests/tests/features/draft-persistence.spec.ts new file mode 100644 index 00000000..c6f9a68a --- /dev/null +++ b/e2e-tests/tests/features/draft-persistence.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '../../fixtures/cdp.fixture'; +import { + closeInterlinearizerTab, + ensureE2eProjectActive, + ensureInterlinearizerOpenOnWeb, + getInterlinearizerFrame, + navigateToScriptureRef, + waitForAppReady, + waitForInterlinearizerReady, + wipeDraft, +} from '../../fixtures/helpers'; + +test.describe('Draft persistence', () => { + test('a glossed draft survives closing and reopening the interlinearizer', async ({ + mainPage, + }) => { + // Two full open cycles (plus first-use project creation and book loading on a cold instance) + // legitimately exceed the default 120 s budget. + test.slow(); + await waitForAppReady(mainPage); + await waitForInterlinearizerReady(); + await ensureInterlinearizerOpenOnWeb(mainPage); + await ensureE2eProjectActive(mainPage); + await navigateToScriptureRef(mainPage, 'GEN 1:1'); + await wipeDraft(mainPage); + + const frame = getInterlinearizerFrame(mainPage); + const glossInput = frame.getByLabel('Gloss for beginning', { exact: true }).first(); + await expect(glossInput).toBeVisible({ timeout: 30_000 }); + + // Unique per run so a leftover value from a previous run can never false-pass. + const gloss = `e2e-persist-${Date.now()}`; + await glossInput.click(); + await glossInput.fill(gloss); + await glossInput.press('Tab'); + await expect(glossInput).toHaveValue(gloss); + + // The draft auto-saves on a 300 ms debounce after the last keystroke, and there is no UI + // signal that the storage write has landed (the tab's dirty marker tracks draft-vs-saved- + // project, not draft-vs-storage). This fixed wait deliberately covers the debounce so the + // test exercises the debounced-save path; the unmount-flush (close immediately after + // typing) path is intentionally out of scope here. + await mainPage.waitForTimeout(1_000); + + await closeInterlinearizerTab(mainPage); + + await ensureInterlinearizerOpenOnWeb(mainPage); + await navigateToScriptureRef(mainPage, 'GEN 1:1'); + + const reopenedFrame = getInterlinearizerFrame(mainPage); + const reopenedGlossInput = reopenedFrame + .getByLabel('Gloss for beginning', { exact: true }) + .first(); + await expect(reopenedGlossInput).toBeVisible({ timeout: 30_000 }); + await expect(reopenedGlossInput).toHaveValue(gloss); + + // Discard this test's leftover gloss (reload the e2e project into the draft) so the next run + // starts clean instead of triggering the dirty-draft rescue. Rescue must stay off here: the + // close/reopen dropped the active-project WebView state, so the dirty draft would otherwise + // look like developer work. + await ensureE2eProjectActive(mainPage, { rescueDirtyDraft: false }); + }); +}); diff --git a/e2e-tests/tests/features/gloss-roundtrip.spec.ts b/e2e-tests/tests/features/gloss-roundtrip.spec.ts new file mode 100644 index 00000000..2b073f10 --- /dev/null +++ b/e2e-tests/tests/features/gloss-roundtrip.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '../../fixtures/cdp.fixture'; +import { + ensureE2eProjectActive, + ensureInterlinearizerOpenOnWeb, + getInterlinearizerFrame, + navigateToScriptureRef, + waitForAppReady, + waitForInterlinearizerReady, + wipeDraft, +} from '../../fixtures/helpers'; + +test.describe('Gloss round-trip', () => { + test('typing a gloss on a token renders it in the gloss field', async ({ mainPage }) => { + await waitForAppReady(mainPage); + await waitForInterlinearizerReady(); + await ensureInterlinearizerOpenOnWeb(mainPage); + await ensureE2eProjectActive(mainPage); + await navigateToScriptureRef(mainPage, 'GEN 1:1'); + await wipeDraft(mainPage); + + const frame = getInterlinearizerFrame(mainPage); + + // WEB Genesis 1:1: "In the beginning, God created the heavens and the earth." + const glossInput = frame.getByLabel('Gloss for beginning', { exact: true }).first(); + await expect(glossInput).toBeVisible({ timeout: 30_000 }); + // The wipe just cleared all analysis, so the field must start empty. + await expect(glossInput).toHaveValue(''); + + // Unique per run so a leftover value from a previous run can never false-pass. + const gloss = `e2e-gloss-${Date.now()}`; + await glossInput.click(); + await glossInput.fill(gloss); + await glossInput.press('Tab'); + + await expect(glossInput).toHaveValue(gloss); + + // Discard this test's leftover gloss (reload the e2e project into the draft) so the next run + // starts clean instead of triggering the dirty-draft rescue. + await ensureE2eProjectActive(mainPage, { rescueDirtyDraft: false }); + }); +}); diff --git a/e2e-tests/tests/features/project-modals.spec.ts b/e2e-tests/tests/features/project-modals.spec.ts new file mode 100644 index 00000000..03709137 --- /dev/null +++ b/e2e-tests/tests/features/project-modals.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '../../fixtures/cdp.fixture'; +import { + ensureInterlinearizerOpenOnWeb, + getInterlinearizerFrame, + openInterlinearizerProjectMenu, + waitForAppReady, + waitForInterlinearizerReady, +} from '../../fixtures/helpers'; + +/** + * The project-related modals reachable from the Interlinearizer's ≡ (Project) menu, each with the + * menu item that opens it and the title element that identifies it (from ModalShell's `titleId`). + * The tour is read-only: each modal is opened, verified, and canceled — no project is created, + * saved, or deleted, so the shared CDP instance is left untouched. + */ +const MODAL_TOURS = [ + { + name: 'Select Interlinear Project', + menuItem: /Select Interlinear Project/i, + titleSelector: '#select-project-modal-title', + }, + { + name: 'New Interlinear Project', + menuItem: /New Interlinear Project/i, + titleSelector: '#create-project-modal-title', + }, + { + name: 'Save As', + menuItem: /^Save As/i, + titleSelector: '#save-as-modal-title', + }, +]; + +test.describe('Project modals cancel tour', () => { + MODAL_TOURS.forEach((modal) => { + test(`the ${modal.name} modal opens from the Project menu and cancels cleanly`, async ({ + mainPage, + }) => { + await waitForAppReady(mainPage); + await waitForInterlinearizerReady(); + await ensureInterlinearizerOpenOnWeb(mainPage); + + const frame = await openInterlinearizerProjectMenu(mainPage); + await frame.getByRole('menuitem', { name: modal.menuItem }).first().click(); + + const modalTitle = frame.locator(modal.titleSelector); + await expect(modalTitle).toBeVisible({ timeout: 10_000 }); + + await frame.locator('dialog').getByRole('button', { name: 'Cancel' }).click(); + await expect(modalTitle).not.toBeVisible({ timeout: 5_000 }); + + // The underlying view must still be interactive after the modal unmounts — a stuck + // overlay is the most common real modal regression. + const projectMenuButton = getInterlinearizerFrame(mainPage) + .locator("button[aria-label='Project']") + .first(); + await expect(projectMenuButton).toBeVisible({ timeout: 5_000 }); + await expect(projectMenuButton).toBeEnabled(); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 12c9754d..5d3961fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3916,17 +3916,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", - "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/type-utils": "8.62.1", - "@typescript-eslint/utils": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -3939,22 +3939,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.62.1", + "@typescript-eslint/parser": "^8.63.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", - "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3" }, "engines": { @@ -3970,14 +3970,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", - "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.1", - "@typescript-eslint/types": "^8.62.1", + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "engines": { @@ -3992,14 +3992,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", - "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1" + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4010,9 +4010,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", - "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", "dev": true, "license": "MIT", "engines": { @@ -4027,15 +4027,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", - "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4052,9 +4052,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", - "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", "dev": true, "license": "MIT", "engines": { @@ -4066,16 +4066,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", - "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.62.1", - "@typescript-eslint/tsconfig-utils": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4094,16 +4094,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", - "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1" + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4118,13 +4118,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", - "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5488,9 +5488,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5585,9 +5585,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", "dev": true, "funding": [ { @@ -5605,10 +5605,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -5804,9 +5804,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001800", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", "dev": true, "funding": [ { @@ -6970,9 +6970,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.382", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.382.tgz", - "integrity": "sha512-8ETaWbV6SZOrno+G93Ffd9ENsMtetqdnqj4nlfxFW90Sm5GgnuV28Kf62hqQVD6VUgzm7qFQKsTsAPmeUiU3Ug==", + "version": "1.5.388", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.388.tgz", + "integrity": "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==", "dev": true, "license": "ISC" }, @@ -7227,9 +7227,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", - "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", "dev": true, "license": "MIT" }, @@ -7506,9 +7506,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", - "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", "dev": true, "license": "MIT", "dependencies": { @@ -8394,9 +8394,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.1.tgz", + "integrity": "sha512-tPb5TTWfgfVx5BNSi2xV0eLr89POeXXn0dXIsCJ9m1narrWxeIyx6je9d7Rce/3NyXLbvuQmLkxq+RuxMWejvw==", "funding": [ { "type": "github", @@ -9237,9 +9237,9 @@ } }, "node_modules/hono": { - "version": "4.12.27", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", - "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "version": "4.12.28", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", + "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", "dev": true, "license": "MIT", "engines": { @@ -9346,9 +9346,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9407,9 +9407,9 @@ } }, "node_modules/immer": { - "version": "11.1.8", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", - "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", "license": "MIT", "funding": { "type": "opencollective", @@ -12075,9 +12075,9 @@ } }, "node_modules/lucide-react": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.22.0.tgz", - "integrity": "sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", + "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", "dev": true, "license": "ISC", "peerDependencies": { @@ -13630,9 +13630,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -15312,9 +15312,9 @@ "license": "ISC" }, "node_modules/shadcn": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.12.0.tgz", - "integrity": "sha512-o781ieQziCnXH2FKsEqxp1fnbHdbgAPO9inTSPeZ59hQfsZXuMGp3ul8oFSV5KQS4nbUK9b+DrDE6C7OvfKKQQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.13.0.tgz", + "integrity": "sha512-5fuJ4jI/GcPeA/iTL4cJivCZuYQGXz/N3bIzyd+Gd/FM6xUCy2MxGG+LaDQuw2cjNy9zGPSFPTEmI048UwPTZA==", "dev": true, "license": "MIT", "dependencies": { @@ -16454,9 +16454,9 @@ } }, "node_modules/systeminformation": { - "version": "5.31.11", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.11.tgz", - "integrity": "sha512-I6O7iaUj23AXRgCPDDnvi3xHvdOLp4+1YMbF+X194lJwY1NeWojgHJPhslVKcmTtrLTguRk3QJK+xEdTiI3P0w==", + "version": "5.31.14", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.14.tgz", + "integrity": "sha512-nefRpMCsAI4m71/6JHH//KPaP/d5nTuRVxEtQ7N7SlBrX18DAcC+5Z1JKZYeN9Iw49qMx95BTo/gBMk3Y2H6+g==", "dev": true, "license": "MIT", "os": [ @@ -17475,9 +17475,9 @@ } }, "node_modules/webpack": { - "version": "5.108.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.3.tgz", - "integrity": "sha512-hOpaCHmQVVY66IVTjofnH14IgSdmod2aquSGHGuYig/OIdWge01Hk2Wt988DZcwXumFUT4+FvJY5N+ikl8o/ww==", + "version": "5.108.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.4.tgz", + "integrity": "sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w==", "dev": true, "license": "MIT", "dependencies": { @@ -17607,9 +17607,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", - "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", "dev": true, "license": "MIT", "engines": { @@ -17617,9 +17617,9 @@ } }, "node_modules/webpack/node_modules/enhanced-resolve": { - "version": "5.24.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz", - "integrity": "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==", + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", "dev": true, "license": "MIT", "dependencies": { @@ -18119,9 +18119,9 @@ } }, "node_modules/yocto-spinner": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.0.tgz", - "integrity": "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.1.tgz", + "integrity": "sha512-9cbFWLhbiZp+820O4pkHGNncI7+MrUGzBOjw8NMG+ewsY+aG0DdEXnr19Smxao32YOjLZRMdn1UtaxcrXOYOIg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx index 926487be..1c43328b 100644 --- a/src/__tests__/components/AnalysisStore.test.tsx +++ b/src/__tests__/components/AnalysisStore.test.tsx @@ -16,6 +16,7 @@ import { useMorphemeDeleteDispatch, useMorphemeGlossDispatch, useMorphemes, + usePhraseLinkByIdGetter, usePhraseLinkByIdMap, usePhraseLinkForToken, usePhraseLinkMap, @@ -684,6 +685,45 @@ describe('usePhraseLinkByIdMap', () => { }); }); +/** + * Renders a component that reads the phrase-link map through `usePhraseLinkByIdGetter` at render + * time and displays its size, used to assert the getter resolves current store state. + * + * @returns JSX element suitable for passing to `render`. + */ +function PhraseLinkByIdGetterReader() { + const getPhraseLinkById = usePhraseLinkByIdGetter(); + return {getPhraseLinkById().size}; +} + +/** + * Renders a component that calls `usePhraseLinkByIdGetter` without a provider, to assert it throws. + * + * @returns Nothing — only mounted to trigger the throw. + */ +function PhraseLinkByIdGetterUser() { + usePhraseLinkByIdGetter(); + return undefined; +} + +describe('usePhraseLinkByIdGetter', () => { + it('returns a getter resolving the current phrase-link-by-id map', () => { + render( + + + , + ); + expect(screen.getByTestId('getter-map-size')).toHaveTextContent('1'); + }); + + it('throws when called outside an AnalysisStoreProvider', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + 'usePhraseLinkByIdGetter must be used inside an AnalysisStoreProvider', + ); + }); +}); + describe('usePhraseDispatch', () => { it('createPhrase adds a new phrase and calls onSave', async () => { const onSave = jest.fn(); diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index dff035e8..4cbdef88 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -7,6 +7,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event'; import type { Book, PhraseAnalysisLink, Token } from 'interlinearizer'; import { useState, type ReactNode } from 'react'; +import { resegmentBook } from 'parsers/papi/resegmentBook'; import type { PhraseDispatch } from '../../components/AnalysisStore'; import ContinuousView from '../../components/ContinuousView'; import { isWordToken } from '../../types/type-guards'; @@ -863,6 +864,54 @@ describe('ContinuousView scroll behavior', () => { expect(scrollIntoViewMock).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' })); }); + it('holds the group centered on animation frames after an external jump within the active segment', () => { + // An external jump landing in the already-active segment never flips committedActiveSegmentId, + // so the layout-effect re-center loop does not run for it; the scroll effect's own hold loop + // must keep the group pinned while late layout (arc padding settling on the slid window) + // shifts the strip after the instant snap. + const book = makeBook(); + const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); + const { rerender } = render(, withAnalysisStore); + + act(() => { + jest.useFakeTimers(); + }); + try { + // tok-1 shares GEN 1:1 with tok-0, so the active segment is unchanged by this jump. + rerender(); + // Complete the fade-out (RECENTER_FADE_MS) so the displayed focus updates and the instant + // snap fires. + scrollIntoViewMock.mockClear(); + act(() => { + jest.advanceTimersByTime(510); + }); + expect(scrollIntoViewMock).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'auto', inline: 'center' }), + ); + + // Frames within the hold window keep re-centering after the snap. + scrollIntoViewMock.mockClear(); + act(() => { + jest.advanceTimersByTime(50); + }); + expect(scrollIntoViewMock).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'auto', inline: 'center' }), + ); + + // Past the deadline the loop stops scheduling further frames. + act(() => { + jest.advanceTimersByTime(500); + }); + scrollIntoViewMock.mockClear(); + act(() => { + jest.advanceTimersByTime(500); + }); + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + it('snaps the link slots (no transition) during an external jump so they do not slide after the fade-in', () => { const book = makeBook(); const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); @@ -1157,6 +1206,49 @@ describe('ContinuousView scroll behavior', () => { }); }); +// --------------------------------------------------------------------------- +// Segmentation edits +// --------------------------------------------------------------------------- + +describe('ContinuousView segmentation edits', () => { + /** + * Reads the inline opacity of the link-slot wrapper between `prevRef` and `nextRef`, the style + * `PhraseSlot` uses to suppress link buttons outside the active segment. + * + * @param container - The render container to query. + * @param prevRef - Token ref on the start side of the slot. + * @param nextRef - Token ref on the end side of the slot. + * @returns The wrapper's inline `opacity` value. + */ + function slotOpacity(container: HTMLElement, prevRef: string, nextRef: string): string { + const icon = container.querySelector( + `[data-prev-ref="${prevRef}"][data-next-ref="${nextRef}"]`, + ); + const wrapper = icon?.parentElement; + if (!(wrapper instanceof HTMLElement)) throw new Error('Expected a link-slot wrapper span'); + return wrapper.style.opacity; + } + + it('keeps the focused segment link buttons active when a merge changes the focused token segment id', () => { + const book = makeBook(); + const props = requiredProps(book, { focusedTokenRef: 'tok-2' }); + props.viewOptions = { ...allFalseViewOptions, hideInactiveLinkButtons: true }; + const { container, rerender } = render(, withAnalysisStore); + + // Focus sits in GEN 1:2, so the slot between its two tokens is active and visible. + expect(slotOpacity(container, 'tok-2', 'tok-3')).toBe('1'); + + // Merge GEN 1:2 into GEN 1:1 — the exact transform the loader applies on a boundary edit. + // Token refs survive, so focus does not move, but the focused token's segment id changes. + const merged = resegmentBook(book, { removedVerseStarts: ['tok-2'], addedStarts: [] }); + rerender(); + + // The committed active segment must follow the merge; a stale id names a segment that no + // longer exists, which would suppress every link button until the next navigation. + expect(slotOpacity(container, 'tok-2', 'tok-3')).toBe('1'); + }); +}); + // --------------------------------------------------------------------------- // RTL layout // --------------------------------------------------------------------------- diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index fde0eb24..770d702d 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -2,16 +2,24 @@ /// /// +import { useLocalizedStrings } from '@papi/frontend/react'; import type { SerializedVerseRef } from '@sillsdev/scripture'; -import { act, render, screen } from '@testing-library/react'; -import type { Book, ScriptureRef, Segment, Token } from 'interlinearizer'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { Book, PhraseAnalysisLink, ScriptureRef, Segment, Token } from 'interlinearizer'; import type { ReactNode } from 'react'; import { useState } from 'react'; +import { resegmentBook } from 'parsers/papi/resegmentBook'; import Interlinearizer from '../../components/Interlinearizer'; import { InterlinearNavProvider } from '../../components/InterlinearNavContext'; +import { + useSegmentation, + type SegmentationContextValue, + type SegmentationDispatch, +} from '../../components/SegmentationStore'; import type { SegmentDisplayMode } from '../../components/SegmentView'; +import type { SegmentLabel } from '../../utils/segment-labels'; import { RECENTER_FADE_MS } from '../../components/recenter-fade'; -import { defaultScrRef, GEN_1_1_BOOK } from '../test-helpers'; +import { defaultScrRef, GEN_1_1_BOOK, makePhraseLink } from '../test-helpers'; import { allFalseViewOptions } from './test-helpers'; jest.mock('lucide-react', () => ({ @@ -22,6 +30,12 @@ jest.mock('lucide-react', () => ({ * @returns An SVG element with `data-testid="locate-fixed-icon"`. */ LocateFixed: () => , + /** + * Stub for the FoldVertical icon used by the between-rows merge control. + * + * @returns An SVG element with `data-testid="fold-vertical-icon"`. + */ + FoldVertical: () => , })); /** @@ -42,10 +56,18 @@ type CapturedContinuousViewProps = { }; let capturedContinuousViewProps: CapturedContinuousViewProps | undefined; +/** + * The segmentation context value seen by the (stubbed) ContinuousView — carries the force-break + * wrapped dispatch built by InterlinearizerInner, so tests can invoke its methods directly. + */ +let capturedSegmentation: SegmentationContextValue | undefined; + /** Props captured from SegmentView renders so tests can assert on what Interlinearizer passes down. */ type CapturedSegmentViewProps = { /** The segment the component is asked to render. */ segment: Segment; + /** The segment's display label — a verse number, lettered portion, or en-dash range. */ + label?: SegmentLabel; /** Controls whether tokens are rendered as chips or as raw baseline text. */ displayMode: SegmentDisplayMode; /** The `Token.ref` string of the currently focused token, if any. */ @@ -64,6 +86,16 @@ let capturedSegmentViewPropsList: CapturedSegmentViewProps[] = []; /** Stable spy for `updatePhrase` — reset between tests via resetMocks. */ const mockUpdatePhrase = jest.fn(); +/** Stable spies for `createPhrase` / `deletePhrase`, asserted on by the force-break tests. */ +const mockCreatePhrase = jest.fn(); +const mockDeletePhrase = jest.fn(); + +/** + * Phrase-link-by-id map served by the mocked `usePhraseLinkByIdGetter`. Force-break tests seed it + * with phrases that straddle a boundary; cleared in the top-level `beforeEach`. + */ +const mockPhraseLinkById = new Map(); + jest.mock('../../components/AnalysisStore', () => ({ __esModule: true, /** @@ -95,18 +127,39 @@ jest.mock('../../components/AnalysisStore', () => ({ * @returns An empty `Map`. */ usePhraseLinkMap: () => new Map(), - usePhraseLinkByIdMap: () => new Map(), + /** + * Returns the test-owned phrase-link map so straddled-boundary tests can seed phrases the + * component's `straddledBoundaryRefs` memo sees. + * + * @returns The current phrase-link-by-id map. + */ + usePhraseLinkByIdMap: () => mockPhraseLinkById, + /** + * Returns a getter over the test-owned phrase-link map so force-break tests can seed straddling + * phrases. + * + * @returns A function returning the current map. + */ + usePhraseLinkByIdGetter: () => () => mockPhraseLinkById, usePhraseDispatch: () => ({ - createPhrase: () => {}, + createPhrase: (...args: Parameters) => mockCreatePhrase(...args), updatePhrase: (...args: Parameters) => mockUpdatePhrase(...args), - deletePhrase: () => {}, + deletePhrase: (...args: Parameters) => mockDeletePhrase(...args), }), })); jest.mock('../../components/ContinuousView', () => ({ __esModule: true, - default: (props: CapturedContinuousViewProps) => { + /** + * ContinuousView stub; captures its props and the segmentation context (the wrapped, + * force-breaking dispatch) so tests can invoke the dispatch directly. + * + * @param props - The props passed by Interlinearizer. + * @returns A minimal div carrying the focused token ref. + */ + default: function ContinuousViewStub(props: CapturedContinuousViewProps) { capturedContinuousViewProps = props; + capturedSegmentation = useSegmentation(); return (
); @@ -397,6 +450,8 @@ function renderInterlinearizer({ chapterLabelInVerse = false, showMorphology = false, showFreeTranslation = false, + segmentationDispatch, + formerBoundaries, }: { book?: Book; continuousScroll?: boolean; @@ -407,12 +462,16 @@ function renderInterlinearizer({ chapterLabelInVerse?: boolean; showMorphology?: boolean; showFreeTranslation?: boolean; + segmentationDispatch?: SegmentationDispatch; + formerBoundaries?: ReadonlyMap; } = {}) { return render( withNav( { // jsdom does not implement scrollIntoView; stub it globally so components that call it don't throw. Element.prototype.scrollIntoView = jest.fn(); + // The phrase-link map is a plain Map (not a jest mock), so resetMocks does not clear it. + mockPhraseLinkById.clear(); + capturedSegmentation = undefined; + // resetMocks clears the shared useLocalizedStrings implementation the between-rows merge + // control's label relies on; re-establish the key-to-itself mapping. + jest + .mocked(useLocalizedStrings) + .mockImplementation((keys: readonly string[]) => [ + keys.reduce>((acc, k) => ({ ...acc, [k]: k }), {}), + false, + ]); }); describe('Interlinearizer', () => { @@ -453,6 +523,24 @@ describe('Interlinearizer', () => { expect(screen.getByText(/no verse data for gen 1\./i)).toBeInTheDocument(); }); + it('passes verse-based segment labels to the segment views', () => { + renderInterlinearizer({ book: GEN_1_MULTI_BOOK }); + + expect(capturedSegmentViewPropsList[0].label).toBe('1'); + expect(capturedSegmentViewPropsList[1].label).toBe('2'); + }); + + it('labels a merged segment with its merged verse range', () => { + const merged = resegmentBook(GEN_1_MULTI_BOOK, { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + renderInterlinearizer({ book: merged }); + + expect(screen.getAllByTestId('segment-view')).toHaveLength(1); + expect(capturedSegmentViewPropsList[0].label).toBe('1–2'); + }); + it('renders a SegmentView for every segment in the current chapter', () => { renderInterlinearizer({ book: GEN_1_MULTI_BOOK }); @@ -1221,3 +1309,383 @@ describe('Interlinearizer', () => { } }); }); + +// --------------------------------------------------------------------------- +// Segmentation dispatch force-break wrapper + between-rows merge control +// --------------------------------------------------------------------------- + +describe('segmentation dispatch force-break', () => { + /** + * Builds a raw segmentation-dispatch spy; the wrapped dispatch the views receive must delegate + * every call to it. + * + * @returns A dispatch whose methods are jest spies. + */ + function makeRawDispatch(): SegmentationDispatch { + return { merge: jest.fn(), split: jest.fn(), move: jest.fn() }; + } + + /** + * Renders with continuous scroll on (so the stubbed ContinuousView captures the segmentation + * context) and returns the wrapped dispatch. + * + * @param raw - The raw dispatch handed to the component. + * @param book - The book fixture to render. + * @returns The wrapped dispatch captured from the segmentation context. + */ + function renderAndCaptureDispatch(raw: SegmentationDispatch, book: Book): SegmentationDispatch { + renderInterlinearizer({ book, continuousScroll: true, segmentationDispatch: raw }); + const dispatch = capturedSegmentation?.dispatch; + if (!dispatch) throw new Error('expected a captured segmentation dispatch'); + return dispatch; + } + + it('passes merge straight through without touching phrases', () => { + const raw = makeRawDispatch(); + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0'])); + const dispatch = renderAndCaptureDispatch(raw, GEN_1_MULTI_BOOK); + dispatch.merge('GEN 1:2:0'); + expect(raw.merge).toHaveBeenCalledWith('GEN 1:2:0'); + expect(mockUpdatePhrase).not.toHaveBeenCalled(); + expect(mockCreatePhrase).not.toHaveBeenCalled(); + expect(mockDeletePhrase).not.toHaveBeenCalled(); + }); + + it('force-breaks a two-token phrase straddling a split boundary (deletes it), then splits', () => { + const raw = makeRawDispatch(); + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0'])); + const dispatch = renderAndCaptureDispatch(raw, GEN_1_MULTI_BOOK); + dispatch.split('GEN 1:2:0'); + // Both halves of the straddled two-token phrase are single tokens, so the phrase is deleted. + expect(mockDeletePhrase).toHaveBeenCalledWith('p1'); + expect(raw.split).toHaveBeenCalledWith('GEN 1:2:0'); + // The break lands before the boundary write so no consumer observes a straddling phrase. + expect(mockDeletePhrase.mock.invocationCallOrder[0]).toBeLessThan( + jest.mocked(raw.split).mock.invocationCallOrder[0], + ); + }); + + it('force-breaks a longer straddling phrase into its two sides at the boundary', () => { + const raw = makeRawDispatch(); + mockPhraseLinkById.set( + 'p1', + makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0', 'GEN 1:3:0', 'GEN 1:4:0']), + ); + const dispatch = renderAndCaptureDispatch(raw, makeLargeBook(4)); + dispatch.split('GEN 1:3:0'); + expect(mockUpdatePhrase).toHaveBeenCalledWith('p1', [ + { tokenRef: 'GEN 1:1:0', surfaceText: 'GEN 1:1:0' }, + { tokenRef: 'GEN 1:2:0', surfaceText: 'GEN 1:2:0' }, + ]); + expect(mockCreatePhrase).toHaveBeenCalledWith([ + { tokenRef: 'GEN 1:3:0', surfaceText: 'GEN 1:3:0' }, + { tokenRef: 'GEN 1:4:0', surfaceText: 'GEN 1:4:0' }, + ]); + expect(raw.split).toHaveBeenCalledWith('GEN 1:3:0'); + }); + + it('leaves phrases alone when the split boundary cuts none of them', () => { + const raw = makeRawDispatch(); + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:3:0', 'GEN 1:4:0'])); + const dispatch = renderAndCaptureDispatch(raw, makeLargeBook(4)); + dispatch.split('GEN 1:3:0'); + expect(mockUpdatePhrase).not.toHaveBeenCalled(); + expect(mockCreatePhrase).not.toHaveBeenCalled(); + expect(mockDeletePhrase).not.toHaveBeenCalled(); + expect(raw.split).toHaveBeenCalledWith('GEN 1:3:0'); + }); + + it('force-breaks at the destination boundary of a move', () => { + const raw = makeRawDispatch(); + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0'])); + const dispatch = renderAndCaptureDispatch(raw, GEN_1_MULTI_BOOK); + dispatch.move('GEN 1:1:0', 'GEN 1:2:0'); + expect(mockDeletePhrase).toHaveBeenCalledWith('p1'); + expect(raw.move).toHaveBeenCalledWith('GEN 1:1:0', 'GEN 1:2:0'); + }); + + it('skips the force-break when the boundary ref is unknown to the book', () => { + const raw = makeRawDispatch(); + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0'])); + const dispatch = renderAndCaptureDispatch(raw, GEN_1_MULTI_BOOK); + dispatch.split('EXO 9:9:9'); + expect(mockDeletePhrase).not.toHaveBeenCalled(); + expect(raw.split).toHaveBeenCalledWith('EXO 9:9:9'); + }); +}); + +describe('between-rows merge control', () => { + it('renders a merge button between adjacent segment rows and merges on click', () => { + const raw: SegmentationDispatch = { merge: jest.fn(), split: jest.fn(), move: jest.fn() }; + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, segmentationDispatch: raw }); + const buttons = screen.getAllByTestId('segment-merge-btn'); + // Two rows -> exactly one gap between them. + expect(buttons).toHaveLength(1); + fireEvent.click(buttons[0]); + // Merging removes the boundary at the lower segment's first token. + expect(raw.merge).toHaveBeenCalledWith('GEN 1:2:0'); + }); + + it('renders no merge button when only one segment row is mounted', () => { + renderInterlinearizer({ book: GEN_1_1_BOOK }); + expect(screen.queryByTestId('segment-merge-btn')).not.toBeInTheDocument(); + }); + + it('outlines and tints the two adjacent rows while the merge button is hovered', () => { + const { container } = renderInterlinearizer({ book: GEN_1_MULTI_BOOK }); + const button = screen.getByTestId('segment-merge-btn'); + expect(container.getElementsByClassName('tw:ring-ring/60')).toHaveLength(0); + fireEvent.mouseEnter(button); + // Both rows around the hovered gap carry the preview outline and tint. + expect(container.getElementsByClassName('tw:ring-ring/60')).toHaveLength(2); + fireEvent.mouseLeave(button); + expect(container.getElementsByClassName('tw:ring-ring/60')).toHaveLength(0); + }); + + it('clears the merge preview synchronously when the merge button is clicked', () => { + const raw: SegmentationDispatch = { merge: jest.fn(), split: jest.fn(), move: jest.fn() }; + const { container } = renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + segmentationDispatch: raw, + }); + const button = screen.getByTestId('segment-merge-btn'); + fireEvent.mouseEnter(button); + fireEvent.click(button); + expect(container.getElementsByClassName('tw:ring-ring/60')).toHaveLength(0); + expect(raw.merge).toHaveBeenCalledWith('GEN 1:2:0'); + }); +}); + +describe('focus preservation across segmentation edits', () => { + /** GEN book whose verse 1 has two word tokens and verse 2 one — lets focus sit off a verse start. */ + const GEN_TWO_TOKEN_V1_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'In beginning.', + tokens: [ + { + ref: 'GEN 1:1:0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + { + ref: 'GEN 1:1:3', + surfaceText: 'beginning', + writingSystem: 'en', + type: 'word', + charStart: 3, + charEnd: 12, + }, + ], + }, + { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: 'And the earth.', + tokens: [ + { + ref: 'GEN 1:2:0', + surfaceText: 'And', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 3, + }, + ], + }, + ], + }; + + /** + * Builds the wrapped `` element used by these tests, so the initial render and + * the post-edit rerender share identical props apart from the book. + * + * @param book - The (re)segmented book to render. + * @param scrRef - The active scripture reference. + * @returns The element wrapped in a nav provider. + */ + function interlinearizerEl(book: Book, scrRef: SerializedVerseRef): ReactNode { + return withNav( + {}} + viewOptions={allFalseViewOptions} + />, + ); + } + + it('keeps the focused token when a merge removes the active verse segment start', () => { + const scrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 2 }; + const { rerender } = render(interlinearizerEl(GEN_1_MULTI_BOOK, scrRef)); + expect(capturedContinuousViewProps?.focusedTokenRef).toBe('GEN 1:2:0'); + // Merge verse 2 into verse 1 — the exact transform the loader applies on a boundary edit. No + // segment starts at the active verse afterwards, but the focused token still exists. + const merged = resegmentBook(GEN_1_MULTI_BOOK, { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + rerender(interlinearizerEl(merged, scrRef)); + expect(capturedContinuousViewProps?.focusedTokenRef).toBe('GEN 1:2:0'); + }); + + it('keeps a deliberately-focused token across a merge into the active verse', () => { + const scrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + const { rerender } = render(interlinearizerEl(GEN_TWO_TOKEN_V1_BOOK, scrRef)); + act(() => capturedContinuousViewProps?.onFocusedTokenRefChange('GEN 1:1:3')); + expect(capturedContinuousViewProps?.focusedTokenRef).toBe('GEN 1:1:3'); + // Merge verse 2 into verse 1; the active verse's segment survives (same start), so the + // deliberately-focused token must not be clobbered back to the verse's first word. + const merged = resegmentBook(GEN_TWO_TOKEN_V1_BOOK, { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + rerender(interlinearizerEl(merged, scrRef)); + expect(capturedContinuousViewProps?.focusedTokenRef).toBe('GEN 1:1:3'); + }); +}); + +describe('former boundaries', () => { + it('provides the supplied formerBoundaries map to the views through the segmentation context', () => { + // Anchor word ref → removed default start ref (distinct when the verse opens with punctuation). + const formerBoundaries: ReadonlyMap = new Map([['GEN 1:2:1', 'GEN 1:2:0']]); + renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + continuousScroll: true, + formerBoundaries, + }); + expect(capturedSegmentation?.formerBoundaries).toBe(formerBoundaries); + }); + + it('defaults formerBoundaries to an empty map when the loader supplies none', () => { + renderInterlinearizer({ book: GEN_1_MULTI_BOOK, continuousScroll: true }); + expect(capturedSegmentation?.formerBoundaries?.size).toBe(0); + }); +}); + +describe('straddled boundary refs', () => { + /** + * Renders with continuous scroll on (so the stubbed ContinuousView captures the segmentation + * context) and returns the straddled-boundary set the component computed. Phrase links must be + * seeded into `mockPhraseLinkById` before calling. + * + * @param book - The book fixture to render. + * @returns The `straddledBoundaryRefs` set captured from the segmentation context. + */ + function renderAndCaptureStraddled(book: Book): ReadonlySet { + renderInterlinearizer({ book, continuousScroll: true }); + const straddled = capturedSegmentation?.straddledBoundaryRefs; + if (!straddled) throw new Error('expected a captured straddled-boundary set'); + return straddled; + } + + it('blocks the word refs strictly inside a contiguous three-word phrase', () => { + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0', 'GEN 1:3:0'])); + const straddled = renderAndCaptureStraddled(makeLargeBook(4)); + // A boundary before the 2nd or 3rd word would cut the phrase. + expect(straddled.has('GEN 1:2:0')).toBe(true); + expect(straddled.has('GEN 1:3:0')).toBe(true); + }); + + it('does not block the phrase-leading word ref or the word after the phrase', () => { + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0', 'GEN 1:3:0'])); + const straddled = renderAndCaptureStraddled(makeLargeBook(4)); + // A boundary before the phrase's first word or after its last word leaves it intact. + expect(straddled.has('GEN 1:1:0')).toBe(false); + expect(straddled.has('GEN 1:4:0')).toBe(false); + }); + + it('blocks the gap word ref inside a discontiguous phrase', () => { + // The phrase spans words 1 and 3; word 2 sits in the gap, but a boundary before it would still + // put the phrase's halves in different segments. + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:3:0'])); + const straddled = renderAndCaptureStraddled(makeLargeBook(4)); + expect(straddled.has('GEN 1:2:0')).toBe(true); + }); + + it('exposes an empty set when no phrase links exist', () => { + const straddled = renderAndCaptureStraddled(makeLargeBook(4)); + expect(straddled.size).toBe(0); + }); +}); + +describe('segmentationVersion pass-through', () => { + /** + * Builds the wrapped `` element for the pass-through tests, varying only the + * book and segmentation version between renders. + * + * @param book - The (re)segmented book to render. + * @param segmentationVersion - The boundary-edit counter forwarded to the segment window. + * @returns The element wrapped in a nav provider. + */ + function versionedEl(book: Book, segmentationVersion: number): ReactNode { + return withNav( + {}} + viewOptions={{ ...allFalseViewOptions }} + />, + ); + } + + it('redraws in place without a recenter fade when a boundary edit bumps segmentationVersion', () => { + const { container, rerender } = render(versionedEl(GEN_1_MULTI_BOOK, 0)); + const merged = resegmentBook(GEN_1_MULTI_BOOK, { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + act(() => { + rerender(versionedEl(merged, 1)); + }); + // Were the prop dropped on the way to the segment window, the segments-identity change would be + // classified as a re-tokenization and fade the list out. + expect(container.querySelector('.tw\\:gap-2.tw\\:transition-opacity')).toHaveStyle({ + opacity: '1', + }); + }); + + it('fades the list for a segments change without a segmentationVersion bump', () => { + jest.useFakeTimers(); + try { + const { container, rerender } = render(versionedEl(GEN_1_MULTI_BOOK, 0)); + const merged = resegmentBook(GEN_1_MULTI_BOOK, { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + act(() => { + rerender(versionedEl(merged, 0)); + }); + // Same segments change, same version: a re-tokenization, so the recenter fade runs. This + // guards the positive case above against passing vacuously in a setup where fades never fire. + expect(container.querySelector('.tw\\:gap-2.tw\\:transition-opacity')).toHaveStyle({ + opacity: '0', + }); + act(() => { + jest.advanceTimersByTime(RECENTER_FADE_MS); + }); + expect(container.querySelector('.tw\\:gap-2.tw\\:transition-opacity')).toHaveStyle({ + opacity: '1', + }); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 6b28da37..be06f038 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -16,6 +16,7 @@ import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import type { PhraseMode } from '../../types/phrase-mode'; import type { ViewOptions } from '../../types/view-options'; +import type { SegmentationDispatch } from '../../components/SegmentationStore'; import { GEN_1_1_BOOK, makeScrollGroupHook, makeWebViewState } from '../test-helpers'; jest.mock('../../hooks/useInterlinearizerBookData'); @@ -154,6 +155,9 @@ type CapturedInterlinearizerProps = { phraseMode: PhraseMode; setPhraseMode: Dispatch>; viewOptions: ViewOptions; + segmentationDispatch: SegmentationDispatch; + formerBoundaries: ReadonlyMap; + segmentationVersion: number; }; let capturedInterlinearizerProps: CapturedInterlinearizerProps | undefined; let interlinearizerMountCount = 0; @@ -202,14 +206,18 @@ jest.mock('../../components/modals/ProjectModals', () => ({ /** * Minimal ProjectModals stand-in that drives modal state and active-project state through the * same `useWebViewState` hook the real component uses, so tests can assert on state transitions - * without mounting the full modal tree. Accepts (and ignores) the draft-related props the loader - * now passes (`dirty`, `getDraftSnapshot`, `loadFromProject`, `markSynced`). + * without mounting the full modal tree. Accepts (and mostly ignores) the draft-related props the + * loader now passes (`hasUnsavedWork`, `getDraftSnapshot`, `loadFromProject`, `markSynced`); + * `hasUnsavedWork` is surfaced as a `data-*` attribute so tests can assert the loader feeds it + * the combined committed-and-pending unsaved state. * * @param modal - Current modal identifier controlling which stub panel is rendered. * @param setModal - Callback to transition to a different modal state. * @param activeProject - The currently active interlinear project, or undefined when none is * selected. * @param defaultAnalysisLanguage - BCP 47 tag forwarded as the create modal's default language. + * @param hasUnsavedWork - Whether the draft has committed-but-unsaved changes or uncommitted + * in-progress typing; gates the discard confirmation in the real component. * @param useWebViewState - Injected hook used to read and write persisted WebView state; must * support the `'activeProject'` key. * @returns A JSX element containing the stub modal panels keyed by `modal`. @@ -219,13 +227,14 @@ jest.mock('../../components/modals/ProjectModals', () => ({ setModal, activeProject, defaultAnalysisLanguage, + hasUnsavedWork, useWebViewState, }: { modal: string; setModal: (m: string) => void; activeProject: MockProject | undefined; defaultAnalysisLanguage?: string; - dirty: boolean; + hasUnsavedWork: boolean; getDraftSnapshot: () => DraftProject | undefined; loadFromProject: (project: unknown) => void; markSynced: () => void; @@ -241,6 +250,7 @@ jest.mock('../../components/modals/ProjectModals', () => ({ data-testid="project-modals" data-modal={modal} data-default-lang={defaultAnalysisLanguage} + data-has-unsaved-work={hasUnsavedWork} data-active-project-name={activeProject?.name} > {modal === 'select' && ( @@ -579,10 +589,54 @@ describe('InterlinearizerLoader', () => { }); }); - it('resolves a mid-segment verse to the owning segment start', async () => { - // A merged segment can span several verses; navigating to a verse inside the span has no - // segment starting at that verse, so the loader resolves to the nearest preceding segment - // start — the segment that contains the verse, never a later segment in the chapter. + it('resolves a verse missing from the text to the nearest preceding segment start, never a later one', async () => { + // A verse absent from the book's content (e.g. bridged away in the source) is contained in no + // segment, so the loader resolves it to the nearest preceding segment start in the chapter — + // skipping the later segment that also sits in the chapter. + const gappedBook: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [ + { + id: 'GEN 3:1', + startRef: { book: 'GEN', chapter: 3, verse: 1 }, + endRef: { book: 'GEN', chapter: 3, verse: 1 }, + baselineText: 'First verse.', + tokens: [], + }, + { + id: 'GEN 3:3', + startRef: { book: 'GEN', chapter: 3, verse: 3 }, + endRef: { book: 'GEN', chapter: 3, verse: 3 }, + baselineText: 'Verse after the gap.', + tokens: [], + }, + ], + }; + mockBookData({ book: gappedBook }); + + await act(async () => { + renderLoader({ + useWebViewScrollGroupScrRef: makeScrollGroupHook({ + book: 'GEN', + chapterNum: 3, + verseNum: 2, + }), + }); + }); + + expect(capturedInterlinearizerProps?.scrRef).toEqual({ + book: 'GEN', + chapterNum: 3, + verseNum: 1, + }); + }); + + it('passes a mid-segment verse through unchanged', async () => { + // A merged segment can span several verses; a verse inside the span is contained in that + // segment even though no segment starts at it, so the loader passes the reference through + // unchanged and the views resolve it to the containing segment. const mergedSegmentBook: Book = { id: 'GEN', bookRef: 'GEN', @@ -619,7 +673,7 @@ describe('InterlinearizerLoader', () => { expect(capturedInterlinearizerProps?.scrRef).toEqual({ book: 'GEN', chapterNum: 3, - verseNum: 1, + verseNum: 2, }); }); @@ -1133,6 +1187,380 @@ describe('InterlinearizerLoader', () => { }); }); + describe('segmentation dispatch', () => { + /** A two-verse book so boundary edits produce real, non-default deltas. */ + const TWO_VERSE_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'Alpha beta.', + tokens: [ + { + ref: 'GEN 1:1:0', + surfaceText: 'Alpha', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 5, + }, + { + ref: 'GEN 1:1:6', + surfaceText: 'beta', + writingSystem: 'en', + type: 'word', + charStart: 6, + charEnd: 10, + }, + ], + }, + { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: 'Gamma.', + tokens: [ + { + ref: 'GEN 1:2:0', + surfaceText: 'Gamma', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 5, + }, + ], + }, + ], + }; + + /** + * Returns the segmentation delta from the most recent saveDraft call. + * + * @returns The persisted draft's `segmentation`, or `undefined` when not set / no call. + */ + function lastPersistedSegmentation(): DraftProject['segmentation'] { + const calls = mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraft'); + const last = calls[calls.length - 1]; + const json = last?.[2]; + return typeof json === 'string' ? JSON.parse(json).segmentation : undefined; + } + + /** + * Returns the segmentation dispatch captured from the rendered interlinearizer, failing the + * test if none was captured. + * + * @returns The captured `segmentationDispatch`. + * @throws If the interlinearizer did not render and capture a dispatch. + */ + function getSegmentationDispatch(): SegmentationDispatch { + const dispatch = capturedInterlinearizerProps?.segmentationDispatch; + if (!dispatch) throw new Error('expected a captured segmentationDispatch'); + return dispatch; + } + + it('persists split, merge, and move boundary edits made through the dispatch', async () => { + mockBookData({ book: TWO_VERSE_BOOK }); + await act(async () => { + renderLoader(); + }); + const dispatch = getSegmentationDispatch(); + + jest.useFakeTimers(); + // Split verse 1 before "beta" — a non-default delta is persisted. + act(() => dispatch.split('GEN 1:1:6')); + act(() => jest.advanceTimersByTime(300)); + expect(lastPersistedSegmentation()).toEqual({ + removedVerseStarts: [], + addedStarts: ['GEN 1:1:6'], + }); + + // Merge verse 2 into its predecessor — adds a removed verse start. + act(() => dispatch.merge('GEN 1:2:0')); + act(() => jest.advanceTimersByTime(300)); + expect(lastPersistedSegmentation()?.removedVerseStarts).toContain('GEN 1:2:0'); + + // Move the verse-2 boundary back onto "beta". "beta" (GEN 1:1:6) already begins a segment from + // the split above, so the move removes the (already-removed) verse-2 default start and re-adds + // the existing "beta" start: the normalized delta is unchanged — verse 1's split boundary and + // verse 2's merged boundary both persist. + act(() => dispatch.move('GEN 1:2:0', 'GEN 1:1:6')); + act(() => jest.advanceTimersByTime(300)); + jest.useRealTimers(); + expect(lastPersistedSegmentation()).toEqual({ + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: ['GEN 1:1:6'], + }); + }); + + it('re-renders the interlinearizer with the new segments in place after a boundary edit', async () => { + // The reported bug: clicking merge/split did not update the view. The resegmented book is + // derived from the draft's ref-held segmentation, and the auto-save's `setDirty(true)` bails + // out of the re-render once the draft is dirty — so the new `book` prop only reaches the + // interlinearizer because `autosaveSegmentation` bumps a dedicated version. Assert the split + // actually reaches the rendered `book` (verse 1 becomes two segments) without a remount. + mockBookData({ book: TWO_VERSE_BOOK }); + await act(async () => { + renderLoader(); + }); + const dispatch = getSegmentationDispatch(); + expect(capturedInterlinearizerProps?.book.segments).toHaveLength(2); + const mountsBefore = interlinearizerMountCount; + + act(() => dispatch.split('GEN 1:1:6')); + + // Verse 1 is now two segments (before/after "beta"), so the book has three segments total, and + // the interlinearizer was updated in place rather than remounted. + expect(capturedInterlinearizerProps?.book.segments).toHaveLength(3); + expect(interlinearizerMountCount).toBe(mountsBefore); + }); + + it('sends the boundary delta on Save and clears the unsaved marker', async () => { + mockBookData({ book: TWO_VERSE_BOOK }); + let result: ReturnType | undefined; + await act(async () => { + result = renderLoader({ + useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }), + }); + }); + const dispatch = getSegmentationDispatch(); + + // A boundary edit dirties the draft, so the tab marker lights up. + act(() => dispatch.merge('GEN 1:2:0')); + expect(result?.updateWebViewDefinition).toHaveBeenCalledWith({ title: 'Interlinearizer ●' }); + + result?.updateWebViewDefinition.mockClear(); + await userEvent.click(screen.getByTestId('tab-toolbar-save')); + + // Save sends the draft's boundary delta (not the "null" clear sentinel) alongside the + // analysis, so the project's boundaries match the analysis just written. + expect(mockSendCommand).toHaveBeenCalledWith( + 'interlinearizer.saveAnalysis', + 'proj-1', + JSON.stringify(emptyAnalysis()), + JSON.stringify({ removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }), + ); + // markSynced received the draft's exact analysis and segmentation references, so its + // identity guard matched and the unsaved marker cleared. + expect(result?.updateWebViewDefinition).toHaveBeenCalledWith({ title: 'Interlinearizer' }); + }); + + it('clears the segmentation field when an edit restores the default segmentation', async () => { + mockBookData({ book: TWO_VERSE_BOOK }); + await act(async () => { + renderLoader(); + }); + const dispatch = getSegmentationDispatch(); + + jest.useFakeTimers(); + // Merging the book's first token is a no-op, so the result is the default segmentation and the + // persisted field is cleared to undefined. + act(() => dispatch.merge('GEN 1:1:0')); + act(() => jest.advanceTimersByTime(300)); + jest.useRealTimers(); + expect(lastPersistedSegmentation()).toBeUndefined(); + }); + }); + + describe('former boundaries', () => { + /** + * A four-verse book covering every former-boundary shape: a word-initial verse (1:3), a verse + * that begins with punctuation so its first word token differs from its first token (1:2), a + * verse with no word token at all (1:4), and a token-less verse (1:5). + */ + const BOUNDARY_SHAPES_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'Alpha.', + tokens: [ + { + ref: 'GEN 1:1:0', + surfaceText: 'Alpha', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 5, + }, + ], + }, + { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: '“Gamma.', + tokens: [ + { + ref: 'GEN 1:2:0', + surfaceText: '“', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }, + { + ref: 'GEN 1:2:1', + surfaceText: 'Gamma', + writingSystem: 'en', + type: 'word', + charStart: 1, + charEnd: 6, + }, + ], + }, + { + id: 'GEN 1:3', + startRef: { book: 'GEN', chapter: 1, verse: 3 }, + endRef: { book: 'GEN', chapter: 1, verse: 3 }, + baselineText: 'Delta.', + tokens: [ + { + ref: 'GEN 1:3:0', + surfaceText: 'Delta', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 5, + }, + ], + }, + { + id: 'GEN 1:4', + startRef: { book: 'GEN', chapter: 1, verse: 4 }, + endRef: { book: 'GEN', chapter: 1, verse: 4 }, + baselineText: '—', + tokens: [ + { + ref: 'GEN 1:4:0', + surfaceText: '—', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }, + ], + }, + { + id: 'GEN 1:5', + startRef: { book: 'GEN', chapter: 1, verse: 5 }, + endRef: { book: 'GEN', chapter: 1, verse: 5 }, + baselineText: '', + tokens: [], + }, + ], + }; + + /** + * Renders the loader on {@link BOUNDARY_SHAPES_BOOK} and returns the captured segmentation + * dispatch so a test can drive boundary edits. + * + * @returns The captured `segmentationDispatch`. + * @throws If the interlinearizer did not render and capture a dispatch. + */ + async function renderBoundaryBook(): Promise { + mockBookData({ book: BOUNDARY_SHAPES_BOOK }); + await act(async () => { + renderLoader(); + }); + const dispatch = capturedInterlinearizerProps?.segmentationDispatch; + if (!dispatch) throw new Error('expected a captured segmentationDispatch'); + return dispatch; + } + + it('passes an empty formerBoundaries map and segmentationVersion 0 before any boundary edit', async () => { + await renderBoundaryBook(); + + expect(capturedInterlinearizerProps?.formerBoundaries.size).toBe(0); + expect(capturedInterlinearizerProps?.segmentationVersion).toBe(0); + }); + + it('maps a merged word-initial verse start to itself and bumps segmentationVersion', async () => { + const dispatch = await renderBoundaryBook(); + + // Merge verse 3 into verse 2: the verse begins with a word token, so the split anchor (its + // first word token) and the removed default start are the same ref. + act(() => dispatch.merge('GEN 1:3:0')); + + expect(capturedInterlinearizerProps?.formerBoundaries.get('GEN 1:3:0')).toBe('GEN 1:3:0'); + expect(capturedInterlinearizerProps?.segmentationVersion).toBe(1); + }); + + it('keys a punct-initial merged verse by its first word token, mapped to the removed start', async () => { + const dispatch = await renderBoundaryBook(); + + // Merge verse 2 into verse 1: the verse opens with a quote mark, so the boundary slot's word + // anchor (the first word token) differs from the removed default start (the punct token). + act(() => dispatch.merge('GEN 1:2:0')); + + expect(capturedInterlinearizerProps?.formerBoundaries.get('GEN 1:2:1')).toBe('GEN 1:2:0'); + expect(capturedInterlinearizerProps?.formerBoundaries.has('GEN 1:2:0')).toBe(false); + }); + + it('skips a merged-away verse that has no word token', async () => { + const dispatch = await renderBoundaryBook(); + + // Merge verse 4 (punctuation only) into verse 3: with no word token there is no split anchor + // to key the former boundary by, so the verse contributes no map entry. + act(() => dispatch.merge('GEN 1:4:0')); + + expect(capturedInterlinearizerProps?.formerBoundaries.size).toBe(0); + }); + + it('does not map verses whose default start was not merged away', async () => { + const dispatch = await renderBoundaryBook(); + + // Only verse 3 is merged; the other verses keep their default boundaries and must stay out + // of the map so their slots do not render former-boundary ticks. + act(() => dispatch.merge('GEN 1:3:0')); + + expect(capturedInterlinearizerProps?.formerBoundaries.size).toBe(1); + expect(capturedInterlinearizerProps?.formerBoundaries.has('GEN 1:1:0')).toBe(false); + expect(capturedInterlinearizerProps?.formerBoundaries.has('GEN 1:2:1')).toBe(false); + }); + + it('shows the loading state when the book unloads while the draft holds removals', async () => { + // Render with a rebuildable element so the same loader instance can be re-invoked after the + // book-data mock changes (the mock mutates hook output, not React state). + mockBookData({ book: BOUNDARY_SHAPES_BOOK }); + const updateWebViewDefinition = jest.fn(() => true); + const scrollGroupHook = makeScrollGroupHook(); + const webViewState = makeWebViewState(); + const buildUi = () => ( + + ); + let view: ReturnType | undefined; + await act(async () => { + view = render(buildUi()); + }); + const dispatch = capturedInterlinearizerProps?.segmentationDispatch; + if (!dispatch) throw new Error('expected a captured segmentationDispatch'); + // Merge so the draft's delta has a removed verse start. + act(() => dispatch.merge('GEN 1:3:0')); + expect(capturedInterlinearizerProps?.formerBoundaries.size).toBe(1); + + // The book unloads (e.g. a cross-book swap): with no verse book to resolve refs against, the + // former boundaries cannot be derived and the loader falls back to the loading curtain. + mockBookData({ book: undefined, isLoading: true }); + view?.rerender(buildUi()); + + expect(screen.getByText('Loading…')).toBeInTheDocument(); + expect(screen.queryByTestId('interlinearizer')).not.toBeInTheDocument(); + }); + }); + describe('save command', () => { it('saves the draft analysis to the active project when Save is clicked with an active project', async () => { const draftAnalysis = emptyAnalysis(); @@ -1150,6 +1578,8 @@ describe('InterlinearizerLoader', () => { 'interlinearizer.saveAnalysis', 'proj-1', JSON.stringify(draftAnalysis), + // The draft has no custom boundaries, so Save sends "null" to clear any stored ones. + 'null', ); }); @@ -1196,6 +1626,46 @@ describe('InterlinearizerLoader', () => { expect(updateWebViewDefinition).toHaveBeenCalledWith({ title: 'Interlinearizer' }); }); + it('reports in-progress typing to ProjectModals as unsaved work so a swap is guarded', async () => { + await act(async () => { + renderLoader(); + }); + + // The persisted draft is clean, so the modal starts with no unsaved work to guard. + expect(screen.getByTestId('project-modals')).toHaveAttribute( + 'data-has-unsaved-work', + 'false', + ); + + // A gloss input begins holding uncommitted text. Even though nothing has committed (the draft + // stays clean), ProjectModals must now treat the draft as having unsaved work so opening or + // creating a project prompts before discarding the in-progress gloss. + act(() => { + capturedInterlinearizerProps?.onPendingEditsChange?.(true); + }); + expect(screen.getByTestId('project-modals')).toHaveAttribute('data-has-unsaved-work', 'true'); + }); + + it('drops the unsaved-work guard once in-progress typing is abandoned', async () => { + await act(async () => { + renderLoader(); + }); + + act(() => { + capturedInterlinearizerProps?.onPendingEditsChange?.(true); + }); + expect(screen.getByTestId('project-modals')).toHaveAttribute('data-has-unsaved-work', 'true'); + + // The edit is reverted or the input unmounts with nothing committed: the guard clears. + act(() => { + capturedInterlinearizerProps?.onPendingEditsChange?.(false); + }); + expect(screen.getByTestId('project-modals')).toHaveAttribute( + 'data-has-unsaved-work', + 'false', + ); + }); + it('logs an error when the saveAnalysis command rejects during Save', async () => { await act(async () => renderLoader({ useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }) }), diff --git a/src/__tests__/components/PhraseBox.test.tsx b/src/__tests__/components/PhraseBox.test.tsx index bd7fcafc..9491b77f 100644 --- a/src/__tests__/components/PhraseBox.test.tsx +++ b/src/__tests__/components/PhraseBox.test.tsx @@ -365,6 +365,23 @@ describe('PhraseBox', () => { expect(phraseBox).toHaveClass('tw:phrase-dimmed'); }); + it('applies the candidate outline when isCandidate is true', () => { + renderBox(); + + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + expect(phraseBox).toHaveClass('tw:phrase-candidate'); + }); + + it('gives the candidate outline precedence over the focused style', () => { + renderBox(); + + // While an operation preview is hovered, showing what it affects matters more than showing + // what is focused. + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + expect(phraseBox).toHaveClass('tw:phrase-candidate'); + expect(phraseBox).not.toHaveClass('tw:phrase-focused'); + }); + it('reddens only the chips whose refs are in splitFreeTokenRefs, leaving the box border neutral', () => { renderBox( /// +import { useLocalizedStrings } from '@papi/frontend/react'; import { fireEvent, render, screen } from '@testing-library/react'; -import type { PhraseAnalysisLink, Token } from 'interlinearizer'; +import type { PhraseAnalysisLink, Segment, Token } from 'interlinearizer'; import type { ReactElement } from 'react'; import { PhraseSlot, @@ -12,7 +13,12 @@ import { type StripItem, } from '../../components/PhraseStripParts'; import { PhraseStripProvider } from '../../components/PhraseStripContext'; +import { + SegmentationProvider, + type SegmentationContextValue, +} from '../../components/SegmentationStore'; import { emptyFocusContext } from '../../types/empty-factories'; +import type { PhraseMode } from '../../types/phrase-mode'; import type { TokenGroup, LinkSlot, FocusContext } from '../../types/token-layout'; import { makePhraseLink, makePhraseStripContext, makeWordToken } from '../test-helpers'; @@ -38,6 +44,7 @@ jest.mock('../../components/PhraseBox', () => ({ tokens, isFocused, isHighlighted, + isCandidate, showControls, showGlossInput, splitFreeTokenRefs, @@ -47,6 +54,7 @@ jest.mock('../../components/PhraseBox', () => ({ tokens: (Token & { type: 'word' })[]; isFocused: boolean; isHighlighted: boolean; + isCandidate: boolean; phraseLink: PhraseAnalysisLink | undefined; groupKey: string; onFocusPhrase: (groupKey: string) => void; @@ -58,6 +66,7 @@ jest.mock('../../components/PhraseBox', () => ({ type="button" data-focused={isFocused ? 'true' : 'false'} data-highlighted={isHighlighted ? 'true' : 'false'} + data-candidate={isCandidate ? 'true' : 'false'} data-controls={showControls ? 'true' : 'false'} data-gloss={showGlossInput ? 'true' : 'false'} data-split-free={[...splitFreeTokenRefs].join(',')} @@ -86,6 +95,17 @@ function mkPunct(ref: string, surfaceText = '.'): Token { /** A minimal no-focus context. */ const NO_FOCUS: FocusContext = emptyFocusContext(); +// Every PhraseSlot render mounts BoundaryControl, whose labels come from useLocalizedStrings; +// resetMocks clears the shared implementation, so re-establish the key-to-itself mapping globally. +beforeEach(() => { + jest + .mocked(useLocalizedStrings) + .mockImplementation((keys: readonly string[]) => [ + keys.reduce>((acc, k) => ({ ...acc, [k]: k }), {}), + false, + ]); +}); + /** Default props shared by PhraseSlot tests. */ function slotProps(slot: LinkSlot): Parameters[0] { return { @@ -298,6 +318,262 @@ describe('PhraseSlot', () => { }); }); +// --------------------------------------------------------------------------- +// PhraseSlot boundary controls +// --------------------------------------------------------------------------- + +describe('PhraseSlot boundary controls', () => { + const groupA: TokenGroup = { + tokens: [makeWordToken('a')], + phraseLink: undefined, + firstIndex: 0, + punctuationBetween: [], + }; + const groupB: TokenGroup = { + tokens: [makeWordToken('b')], + phraseLink: undefined, + firstIndex: 1, + punctuationBetween: [], + }; + const slot: LinkSlot = { prevGroup: groupA, nextGroup: groupB, punctuation: [] }; + + /** The segment holding the intra-segment fixture tokens `a` and `b`. */ + const prevSegment: Segment = { + id: 'seg-1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'a b', + tokens: [makeWordToken('a'), makeWordToken('b')], + }; + + /** A segment whose first token ref identifies the boundary the merge control removes. */ + const nextSegment: Segment = { + id: 'seg-2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: 'b', + tokens: [makeWordToken('seg2-start')], + }; + + /** + * Renders a PhraseSlot inside both providers. + * + * @param props - Overrides for the slot props (e.g. prev/next segment ids). + * @param options - Optional fixture overrides: merged-away boundaries, straddled boundary refs, + * the phrase mode, and the candidate-token hover spy. + * @returns The dispatch, for asserting on its calls. + */ + function renderBoundary( + props: Partial[0]>, + options: { + formerBoundaries?: ReadonlyMap; + straddledBoundaryRefs?: ReadonlySet; + phraseMode?: PhraseMode; + onHoverCandidateTokens?: (refs: readonly string[] | undefined) => void; + } = {}, + ) { + const dispatch = { + merge: jest.fn(), + split: jest.fn(), + move: jest.fn(), + }; + const value: SegmentationContextValue = { + dispatch, + segmentById: new Map([ + ['seg-1', prevSegment], + ['seg-2', nextSegment], + ]), + segmentOrder: new Map([ + ['seg-1', 0], + ['seg-2', 1], + ]), + formerBoundaries: options.formerBoundaries ?? new Map(), + straddledBoundaryRefs: options.straddledBoundaryRefs ?? new Set(), + }; + render( + + + + + , + ); + return dispatch; + } + + it('shows a merge control on a cross-segment slot and merges on click', () => { + const dispatch = renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' }); + const button = screen.getByTestId('boundary-merge-btn'); + fireEvent.click(button); + expect(dispatch.merge).toHaveBeenCalledWith('seg2-start'); + expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument(); + }); + + it('shows a split control on an intra-segment slot and splits on click', () => { + const dispatch = renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }); + const button = screen.getByTestId('boundary-split-btn'); + fireEvent.click(button); + // The next group's first token ref is the split anchor. + expect(dispatch.split).toHaveBeenCalledWith('b'); + expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument(); + }); + + it('renders the control always visible, with no hover gating', () => { + renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }); + const wrapper = screen.getByTestId('boundary-split-btn').parentElement; + expect(wrapper?.className).not.toContain('tw:opacity-0'); + expect(wrapper?.className).not.toContain('tw:group-hover/slot:opacity-100'); + }); + + it('renders no split control at a leading slot inside a segment (the boundary already exists)', () => { + // A SegmentView strip's leading slot has no group before it but carries the segment's id on + // both sides; splitting at the segment's first token would be a no-op, so no control renders. + const leadingSlot: LinkSlot = { prevGroup: undefined, nextGroup: groupB, punctuation: [] }; + renderBoundary({ slot: leadingSlot, prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }); + expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument(); + }); + + it('hides the split control at a straddled boundary ref (not-mid-phrase UI guard)', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { straddledBoundaryRefs: new Set(['b']) }, + ); + expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument(); + }); + + it('keeps the split control when the anchor is not among the straddled boundary refs', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { straddledBoundaryRefs: new Set(['other-ref']) }, + ); + expect(screen.getByTestId('boundary-split-btn')).toBeInTheDocument(); + }); + + it('keeps the merge control at a straddled boundary ref (merge never cuts a phrase)', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' }, + { straddledBoundaryRefs: new Set(['b']) }, + ); + expect(screen.getByTestId('boundary-merge-btn')).toBeInTheDocument(); + }); + + it('renders no control at a leading slot with no previous segment', () => { + renderBoundary({ + prevSegmentId: undefined, + nextSegmentId: 'seg-1', + }); + expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument(); + }); + + it('previews a merge on hover by highlighting the word tokens of both segments', () => { + const onHoverCandidateTokens = jest.fn(); + renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' }, { onHoverCandidateTokens }); + fireEvent.mouseEnter(screen.getByTestId('boundary-merge-btn')); + expect(onHoverCandidateTokens).toHaveBeenCalledWith(['a', 'b', 'seg2-start']); + fireEvent.mouseLeave(screen.getByTestId('boundary-merge-btn')); + expect(onHoverCandidateTokens).toHaveBeenLastCalledWith(undefined); + }); + + it('previews a split on hover by highlighting the word tokens that would break off', () => { + const onHoverCandidateTokens = jest.fn(); + renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, { onHoverCandidateTokens }); + fireEvent.mouseEnter(screen.getByTestId('boundary-split-btn')); + // The split anchor is 'b' (the next group's first token); only the run from it to the segment + // end becomes the new segment. + expect(onHoverCandidateTokens).toHaveBeenCalledWith(['b']); + fireEvent.mouseLeave(screen.getByTestId('boundary-split-btn')); + expect(onHoverCandidateTokens).toHaveBeenLastCalledWith(undefined); + }); + + it('previews nothing when the split slot segment id is not in the segment lookup', () => { + const onHoverCandidateTokens = jest.fn(); + renderBoundary({ prevSegmentId: 'seg-x', nextSegmentId: 'seg-x' }, { onHoverCandidateTokens }); + fireEvent.mouseEnter(screen.getByTestId('boundary-split-btn')); + expect(onHoverCandidateTokens).toHaveBeenCalledWith([]); + }); + + it('clears the hover preview synchronously when the control is clicked', () => { + const onHoverCandidateTokens = jest.fn(); + const dispatch = renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { onHoverCandidateTokens }, + ); + const button = screen.getByTestId('boundary-split-btn'); + fireEvent.mouseEnter(button); + fireEvent.click(button); + expect(onHoverCandidateTokens).toHaveBeenLastCalledWith(undefined); + expect(dispatch.split).toHaveBeenCalledWith('b'); + }); + + it('dispatches the original removed ref when splitting at a former boundary', () => { + // The merged-away verse began with punctuation: the word anchor 'b' maps to the removed + // punctuation start ref, and the restore must dispatch that original ref. + const dispatch = renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { formerBoundaries: new Map([['b', 'punct-start']]) }, + ); + fireEvent.click(screen.getByTestId('boundary-split-btn')); + expect(dispatch.split).toHaveBeenCalledWith('punct-start'); + }); + + it('disables the merge control while a phrase edit is active', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' }, + { phraseMode: { kind: 'edit', phraseId: 'p1', originalTokens: [] } }, + ); + expect(screen.getByTestId('boundary-merge-btn')).toBeDisabled(); + }); + + it('disables the split control while a confirm-unlink prompt is active', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { phraseMode: { kind: 'confirm-unlink', phraseId: 'p1' } }, + ); + expect(screen.getByTestId('boundary-split-btn')).toBeDisabled(); + }); + + it('renders a former-boundary tick on an intra-segment slot at a merged-away verse start', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { formerBoundaries: new Map([['b', 'b']]) }, + ); + expect(screen.getByTestId('former-boundary-marker')).toBeInTheDocument(); + expect(screen.getByTestId('boundary-split-btn')).toBeInTheDocument(); + }); + + it('renders no former-boundary tick when the slot is not on a merged-away verse start', () => { + renderBoundary({ prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }); + expect(screen.queryByTestId('former-boundary-marker')).not.toBeInTheDocument(); + }); + + it('keeps the former-boundary tick when the split control is suppressed by the mid-phrase guard', () => { + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' }, + { formerBoundaries: new Map([['b', 'b']]), straddledBoundaryRefs: new Set(['b']) }, + ); + expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument(); + expect(screen.getByTestId('former-boundary-marker')).toBeInTheDocument(); + }); + + it('renders no former-boundary tick on a cross-segment slot', () => { + // A cross-segment slot sits on a live boundary; the tick marks only merged-away ones. + renderBoundary( + { prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' }, + { formerBoundaries: new Map([['b', 'b']]) }, + ); + expect(screen.getByTestId('boundary-merge-btn')).toBeInTheDocument(); + expect(screen.queryByTestId('former-boundary-marker')).not.toBeInTheDocument(); + }); +}); + // --------------------------------------------------------------------------- // PhraseGroup // --------------------------------------------------------------------------- @@ -314,6 +590,7 @@ describe('MemoizedPhraseGroup', () => { group, isFocused: false, isHighlighted: false, + isCandidate: false, splitFreeTokenRefs: new Set(), showControls: false, showGlossInput: true, @@ -340,6 +617,11 @@ describe('MemoizedPhraseGroup', () => { expect(document.querySelector('[data-highlighted="true"]')).toBeInTheDocument(); }); + it('passes isCandidate=true to PhraseBox when set', () => { + render(); + expect(document.querySelector('[data-candidate="true"]')).toBeInTheDocument(); + }); + it('does not attach hover handlers when allowHover is false', () => { const onHoverPhrase = jest.fn(); const setHoveredGroupKey = jest.fn(); @@ -439,14 +721,32 @@ describe('PhraseStrip', () => { expect(boxes[1]).toHaveAttribute('data-gloss', 'false'); }); - it('highlights a group whose token is a link candidate', () => { + it('marks a group whose token is a hovered-preview candidate as candidate, not highlighted', () => { const items = [groupItem(undefined, ['tok-a'])]; render( withProvider( , ), ); - expect(document.querySelector('[data-highlighted="true"]')).toBeInTheDocument(); + // The candidate preview renders through its own dedicated tier — it must not double as the + // hover highlight, which also reveals phrase edit controls. + expect(document.querySelector('[data-candidate="true"]')).toBeInTheDocument(); + expect(document.querySelector('[data-highlighted="true"]')).not.toBeInTheDocument(); + }); + + it('does not mark candidate groups outside view mode', () => { + const items = [groupItem(undefined, ['tok-a'])]; + render( + withProvider( + , + ), + ); + expect(document.querySelector('[data-candidate="true"]')).not.toBeInTheDocument(); }); it('highlights a group whose phraseId matches the focused phrase', () => { diff --git a/src/__tests__/components/SegmentView.test.tsx b/src/__tests__/components/SegmentView.test.tsx index a669ea3e..3c12ef0a 100644 --- a/src/__tests__/components/SegmentView.test.tsx +++ b/src/__tests__/components/SegmentView.test.tsx @@ -223,6 +223,7 @@ function requiredProps(): { onHoverPhrase: jest.Mock; onSelect: (ref: ScriptureRef, tokenRef?: string) => void; segment: Segment; + label: string; phraseMode: { kind: 'view' }; setPhraseMode: jest.Mock; tokenSegmentMap: ReadonlyMap; @@ -239,6 +240,7 @@ function requiredProps(): { onHoverPhrase: jest.fn(), onSelect: jest.fn(), segment: WORD_SEGMENT, + label: '1', phraseMode: { kind: 'view' }, setPhraseMode: jest.fn(), tokenSegmentMap: new Map(), @@ -293,20 +295,26 @@ describe('SegmentView', () => { expect(screen.queryByText('the')).not.toBeInTheDocument(); }); - it('shows the verse number label', () => { + it('shows the verse-based label', () => { render(, withAnalysisStore); expect(screen.getByText('1')).toBeInTheDocument(); }); - it('shows a bare verse number by default', () => { + it('shows a multi-verse range label for a merged segment', () => { + render(, withAnalysisStore); + + expect(screen.getByText('2–3')).toBeInTheDocument(); + }); + + it('shows a bare verse label by default', () => { render(, withAnalysisStore); expect(screen.getByText('1')).toBeInTheDocument(); expect(screen.queryByText('1:1')).not.toBeInTheDocument(); }); - it('folds the chapter into the verse label when chapterLabelInVerse is set', () => { + it('folds the chapter into the segment label when chapterLabelInVerse is set', () => { render( +/// + +import { render, screen } from '@testing-library/react'; +import type { Segment } from 'interlinearizer'; +import { + NO_OP_SEGMENTATION_DISPATCH, + SegmentationProvider, + useSegmentation, + type SegmentationContextValue, +} from '../../components/SegmentationStore'; + +/** A test consumer that renders the resolved context as text so tests can assert on it. */ +function Probe() { + const { segmentById, segmentOrder } = useSegmentation(); + return ( + + {segmentById.size}:{segmentOrder.size} + + ); +} + +describe('SegmentationStore', () => { + it('returns an inert default when no provider is present', () => { + render(); + expect(screen.getByTestId('probe')).toHaveTextContent('0:0'); + }); + + it('provides the supplied value to consumers within a provider', () => { + const segment: Segment = { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'Hi.', + tokens: [], + }; + const value: SegmentationContextValue = { + dispatch: NO_OP_SEGMENTATION_DISPATCH, + segmentById: new Map([['GEN 1:1', segment]]), + segmentOrder: new Map([['GEN 1:1', 0]]), + formerBoundaries: new Map(), + straddledBoundaryRefs: new Set(), + }; + render( + + + , + ); + expect(screen.getByTestId('probe')).toHaveTextContent('1:1'); + }); + + it('exposes an inert no-op dispatch that does nothing when invoked', () => { + // Calling each method must not throw; this also exercises the no-op function bodies. + expect(() => { + NO_OP_SEGMENTATION_DISPATCH.merge('GEN 1:1:0'); + NO_OP_SEGMENTATION_DISPATCH.split('GEN 1:1:6'); + NO_OP_SEGMENTATION_DISPATCH.move('GEN 1:1:0', 'GEN 1:1:6'); + }).not.toThrow(); + }); +}); diff --git a/src/__tests__/components/TokenLinkIcon.test.tsx b/src/__tests__/components/TokenLinkIcon.test.tsx index bbe5b6dc..89d54d8a 100644 --- a/src/__tests__/components/TokenLinkIcon.test.tsx +++ b/src/__tests__/components/TokenLinkIcon.test.tsx @@ -4,12 +4,18 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { Segment, Token } from 'interlinearizer'; import type { ComponentProps, ReactElement } from 'react'; import { TokenLinkIcon } from '../../components/TokenLinkIcon'; import { PhraseStripProvider, type PhraseStripContextValue, } from '../../components/PhraseStripContext'; +import { + SegmentationProvider, + type SegmentationContextValue, + type SegmentationDispatch, +} from '../../components/SegmentationStore'; import type { SlotFocusInfo } from '../../types/token-layout'; import { makePhraseLink, makePhraseStripContext, makeWordToken } from '../test-helpers'; @@ -48,6 +54,7 @@ function slotFocus(overrides: Partial = {}): SlotFocusInfo { return { focusedSideIsPrev: undefined, isSameSegmentAsFocus: true, + isAdjacentEdgeOfFocus: false, focusedPhraseLink: undefined, focusedFreeToken: undefined, ...overrides, @@ -531,4 +538,250 @@ describe('TokenLinkIcon', () => { await userEvent.hover(screen.getByTestId('token-unlink-btn')); expect(onHoverSplitFreeTokens).not.toHaveBeenCalled(); }); + + // --------------------------------------------------------------------------- + // Cross-segment edge link (pulls an adjacent segment's edge token + moves the boundary) + // --------------------------------------------------------------------------- + + describe('cross-segment edge link', () => { + /** A fresh segmentation dispatch whose calls the tests assert on. */ + function makeDispatch(): jest.Mocked { + return { merge: jest.fn(), split: jest.fn(), move: jest.fn() }; + } + + /** + * Builds a punctuation token fixture for adjacent-segment layouts that interleave punctuation + * with the word tokens. + * + * @param ref - Token ref. + * @returns A punctuation token. + */ + function mkPunct(ref: string): Token { + return { + ref, + surfaceText: ',', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }; + } + + /** + * Builds an adjacent segment ("seg-B") containing the given tokens in order. + * + * @param tokens - The segment's tokens, in document order. + * @returns A segment with id `seg-B`. + */ + function segB(tokens: readonly Token[]): Segment { + return { + id: 'seg-B', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: tokens.map((t) => t.surfaceText).join(' '), + tokens: [...tokens], + }; + } + + /** + * Renders a cross-segment edge `TokenLinkIcon` inside both providers. + * + * @param dispatch - The segmentation dispatch to capture calls on. + * @param opts - `focusedSideIsPrev` and the adjacent segment's tokens. + * @returns The render result. + */ + function renderEdge( + dispatch: SegmentationDispatch, + opts: { focusedSideIsPrev: boolean; segmentTokens: readonly Token[]; mapToken?: boolean }, + ) { + const segmentation: SegmentationContextValue = { + dispatch, + segmentById: new Map([['seg-B', segB(opts.segmentTokens)]]), + segmentOrder: new Map([ + ['seg-A', 0], + ['seg-B', 1], + ]), + formerBoundaries: new Map(), + straddledBoundaryRefs: new Set(), + }; + const tokenSegmentMap = + opts.mapToken === false ? new Map() : new Map([['tok-b', 'seg-B']]); + return render( + + + + + , + ); + } + + it('activates the link button at an adjacent edge even across a segment boundary', () => { + renderEdge(makeDispatch(), { + focusedSideIsPrev: true, + segmentTokens: [makeWordToken('tok-b'), makeWordToken('tok-c')], + }); + expect(screen.getByTestId('token-link-btn')).toBeEnabled(); + }); + + it('pulls one token forward (move) when focus is the previous segment', async () => { + const dispatch = makeDispatch(); + renderEdge(dispatch, { + focusedSideIsPrev: true, + segmentTokens: [makeWordToken('tok-b'), makeWordToken('tok-c')], + }); + await userEvent.click(screen.getByTestId('token-link-btn')); + // The adjacent segment B starts at tok-b; pulling tok-b leaves tok-c as B's new start. + expect(dispatch.move).toHaveBeenCalledWith('tok-b', 'tok-c'); + expect(mockCreatePhrase).toHaveBeenCalled(); + }); + + it('moves the boundary to the next word token, skipping punctuation after the pulled word', async () => { + // Segment B is [tok-b, punct, tok-c]: the boundary must land on a word token, so the comma + // after the pulled tok-b travels with it and tok-c becomes B's new start. + const dispatch = makeDispatch(); + renderEdge(dispatch, { + focusedSideIsPrev: true, + segmentTokens: [makeWordToken('tok-b'), mkPunct('punct-1'), makeWordToken('tok-c')], + }); + await userEvent.click(screen.getByTestId('token-link-btn')); + expect(dispatch.move).toHaveBeenCalledWith('tok-b', 'tok-c'); + }); + + it('merges the whole adjacent segment when it has only the pulled token', async () => { + const dispatch = makeDispatch(); + renderEdge(dispatch, { focusedSideIsPrev: true, segmentTokens: [makeWordToken('tok-b')] }); + await userEvent.click(screen.getByTestId('token-link-btn')); + expect(dispatch.merge).toHaveBeenCalledWith('tok-b'); + }); + + it('merges the whole adjacent segment when only punctuation follows the pulled token', async () => { + // Segment B is [tok-b, punct]: no word token remains past tok-b, so a move would strand a + // word-less remainder — the segment merges wholly into the focused one instead. + const dispatch = makeDispatch(); + renderEdge(dispatch, { + focusedSideIsPrev: true, + segmentTokens: [makeWordToken('tok-b'), mkPunct('punct-1')], + }); + await userEvent.click(screen.getByTestId('token-link-btn')); + expect(dispatch.merge).toHaveBeenCalledWith('tok-b'); + expect(dispatch.move).not.toHaveBeenCalled(); + }); + + it('moves the boundary back to the pulled token when focus is the next segment', async () => { + const dispatch = makeDispatch(); + renderEdge(dispatch, { + focusedSideIsPrev: false, + segmentTokens: [makeWordToken('tok-b'), makeWordToken('tok-c')], + }); + await userEvent.click(screen.getByTestId('token-link-btn')); + // Focus is segment B (starting at tok-b); pulling the previous segment's tok-a moves B's start to tok-a. + expect(dispatch.move).toHaveBeenCalledWith('tok-b', 'tok-a'); + }); + + it('skips the boundary move but still phrases when the pulled token maps to no segment', async () => { + const dispatch = makeDispatch(); + renderEdge(dispatch, { + focusedSideIsPrev: true, + segmentTokens: [makeWordToken('tok-b'), makeWordToken('tok-c')], + mapToken: false, + }); + await userEvent.click(screen.getByTestId('token-link-btn')); + expect(dispatch.move).not.toHaveBeenCalled(); + expect(dispatch.merge).not.toHaveBeenCalled(); + expect(mockCreatePhrase).toHaveBeenCalled(); + }); + + it('leaves the link button inactive (with tooltip) for a non-edge cross-segment slot', () => { + render( + + + , + ); + const button = screen.getByTestId('token-link-btn'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', 'nope'); + }); + + // ------------------------------------------------------------------------- + // The not-mid-phrase UI guard: a pull moves the boundary by one token, so a pulled edge token + // that belongs to a phrase would leave that phrase straddling the boundary — the icon disables. + // ------------------------------------------------------------------------- + + describe('pulled edge token in a phrase', () => { + /** + * Renders a cross-segment edge slot whose pulled edge token belongs to a phrase in the + * adjacent segment. `focusedSideIsPrev` picks which side holds focus; the pulled token is on + * the opposite side (`tok-b` when focus is prev-ward, `tok-a` when focus is next-ward). + * + * @param opts - Which side holds focus. + * @returns The render result. + */ + function renderPhrasePull(opts: { focusedSideIsPrev: boolean }) { + const segmentation: SegmentationContextValue = { + dispatch: makeDispatch(), + segmentById: new Map([['seg-B', segB([makeWordToken('tok-b'), makeWordToken('tok-c')])]]), + segmentOrder: new Map([ + ['seg-A', 0], + ['seg-B', 1], + ]), + formerBoundaries: new Map(), + straddledBoundaryRefs: new Set(), + }; + const tokenSegmentMap = new Map([ + ['tok-a', 'seg-A'], + ['tok-b', 'seg-B'], + ]); + const pulledLink = opts.focusedSideIsPrev + ? { nextPhraseLink: makePhraseLink('pB', ['tok-b', 'tok-c']) } + : { prevPhraseLink: makePhraseLink('pA', ['tok-z', 'tok-a']) }; + return render( + + + + + , + ); + } + + it('disables the link when the pulled next-side token belongs to a phrase', () => { + renderPhrasePull({ focusedSideIsPrev: true }); + const button = screen.getByTestId('token-link-btn'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', 'nope'); + }); + + it('disables the link when the pulled prev-side token belongs to a phrase', () => { + renderPhrasePull({ focusedSideIsPrev: false }); + const button = screen.getByTestId('token-link-btn'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', 'nope'); + }); + }); + }); }); diff --git a/src/__tests__/components/modals/ProjectModals.test.tsx b/src/__tests__/components/modals/ProjectModals.test.tsx index 2d8e60b0..1cde5caf 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -39,6 +39,16 @@ const MOCK_FULL_PROJECT_WITH_TARGET = { analysis: emptyAnalysis(), }; +/** A custom boundary delta used to exercise the segmentation branches of open and Save As. */ +const MOCK_SEGMENTATION = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + +/** The same project carrying stored segment boundaries, for the openProject segmentation branch. */ +const MOCK_FULL_PROJECT_WITH_SEGMENTATION = { + ...MOCK_PROJECT, + analysis: emptyAnalysis(), + segmentation: MOCK_SEGMENTATION, +}; + /** Draft snapshot returned by the default `getDraftSnapshot`. */ const MOCK_DRAFT: DraftProject = { sourceProjectId: 'source-proj', @@ -49,6 +59,12 @@ const MOCK_DRAFT: DraftProject = { suggestedDescription: 'Suggested Desc', }; +/** The same draft carrying a custom boundary delta, for the Save As segmentation branches. */ +const MOCK_DRAFT_WITH_SEGMENTATION: DraftProject = { + ...MOCK_DRAFT, + segmentation: MOCK_SEGMENTATION, +}; + jest.mock('../../../components/modals/SelectInterlinearProjectModal', () => ({ __esModule: true, SelectInterlinearProjectModal: ({ @@ -243,7 +259,7 @@ jest.mock('../../../components/modals/ProjectMetadataModal', () => ({ type ModalsOverrides = Partial<{ activeProject: InterlinearProjectSummary | undefined; defaultAnalysisLanguage: string; - dirty: boolean; + hasUnsavedWork: boolean; getDraftSnapshot: () => DraftProject | undefined; loadFromProject: jest.Mock; newDraft: jest.Mock; @@ -264,7 +280,7 @@ function buildProps(overrides: ModalsOverrides = {}) { return { activeProject: overrides.activeProject, defaultAnalysisLanguage: overrides.defaultAnalysisLanguage, - dirty: overrides.dirty ?? false, + hasUnsavedWork: overrides.hasUnsavedWork ?? false, getDraftSnapshot: overrides.getDraftSnapshot ?? (() => MOCK_DRAFT), loadFromProject: overrides.loadFromProject ?? jest.fn(), newDraft: overrides.newDraft ?? jest.fn(), @@ -387,6 +403,39 @@ describe('ProjectModals', () => { ); }); + it('passes a stored segmentation delta into the draft when the project has one', async () => { + jest + .mocked(papi.commands.sendCommand) + .mockResolvedValueOnce(JSON.stringify(MOCK_FULL_PROJECT_WITH_SEGMENTATION)); + const loadFromProject = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('select-select')); + + await waitFor(() => + expect(loadFromProject).toHaveBeenCalledWith({ + analysisLanguages: ['en'], + segmentation: MOCK_SEGMENTATION, + analysis: emptyAnalysis(), + }), + ); + }); + + it('notifies and does not load when the stored segmentation is malformed', async () => { + jest + .mocked(papi.commands.sendCommand) + .mockResolvedValueOnce( + JSON.stringify({ ...MOCK_PROJECT, analysis: emptyAnalysis(), segmentation: { bad: 1 } }), + ); + const loadFromProject = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('select-select')); + + await waitFor(() => expect(papi.notifications.send).toHaveBeenCalledTimes(1)); + expect(loadFromProject).not.toHaveBeenCalled(); + }); + it('notifies and does not load when getProject returns a non-project shape', async () => { jest .mocked(papi.commands.sendCommand) @@ -584,7 +633,11 @@ describe('ProjectModals', () => { .mocked(papi.commands.sendCommand) .mockResolvedValueOnce(JSON.stringify(MOCK_FULL_PROJECT)); const loadFromProject = jest.fn(); - render(); + render( + , + ); await userEvent.click(screen.getByTestId('select-select')); // The discard confirm overlays the still-mounted select modal (so confirming Open does not @@ -599,7 +652,11 @@ describe('ProjectModals', () => { it('cancels the discard confirm and returns to the select modal', async () => { const loadFromProject = jest.fn(); - render(); + render( + , + ); await userEvent.click(screen.getByTestId('select-select')); await userEvent.click(screen.getByTestId('discard-cancel')); @@ -611,7 +668,9 @@ describe('ProjectModals', () => { it('confirms before creating a project when the draft is dirty', async () => { const newDraft = jest.fn(); - render(); + render( + , + ); await userEvent.click(screen.getByTestId('create-submit')); expect(screen.getByTestId('discard-modal')).toBeInTheDocument(); @@ -652,7 +711,7 @@ describe('ProjectModals', () => { { resolveGet = resolve; }), ); - render(); + render(); await userEvent.click(screen.getByTestId('select-select')); expect(screen.getByTestId('discard-confirm')).toBeEnabled(); @@ -706,15 +765,48 @@ describe('ProjectModals', () => { 'NewDesc', ), ); + // The draft has no custom boundaries, so the boundary argument is the "null" clear sentinel. expect(papi.commands.sendCommand).toHaveBeenCalledWith( 'interlinearizer.saveAnalysis', 'proj-1', JSON.stringify(emptyAnalysis()), + 'null', ); expect(markSynced).toHaveBeenCalledTimes(1); + expect(markSynced).toHaveBeenCalledWith(MOCK_DRAFT.analysis, undefined); expect(setModal).toHaveBeenCalledWith('none'); }); + it('sends the draft segmentation delta when saving as a new project', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); + const markSynced = jest.fn(); + render( + MOCK_DRAFT_WITH_SEGMENTATION, + })} + />, + ); + + await userEvent.click(screen.getByTestId('saveas-new')); + + await waitFor(() => + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.saveAnalysis', + 'proj-1', + JSON.stringify(emptyAnalysis()), + JSON.stringify(MOCK_SEGMENTATION), + ), + ); + // markSynced receives the exact persisted references so its identity guard can match. + expect(markSynced).toHaveBeenCalledWith( + MOCK_DRAFT_WITH_SEGMENTATION.analysis, + MOCK_SEGMENTATION, + ); + }); + it('notifies and does not mark synced when create returns a non-project shape', async () => { jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify({ bad: true })); const markSynced = jest.fn(); @@ -744,17 +836,49 @@ describe('ProjectModals', () => { await userEvent.click(screen.getByTestId('saveas-overwrite')); + // The draft has no custom boundaries, so "null" clears any the target had stored. await waitFor(() => expect(papi.commands.sendCommand).toHaveBeenCalledWith( 'interlinearizer.saveAnalysis', 'proj-1', JSON.stringify(emptyAnalysis()), + 'null', ), ); expect(markSynced).toHaveBeenCalledTimes(1); + expect(markSynced).toHaveBeenCalledWith(MOCK_DRAFT.analysis, undefined); expect(setModal).toHaveBeenCalledWith('none'); }); + it('sends the draft segmentation delta when overwriting an existing project', async () => { + const markSynced = jest.fn(); + render( + MOCK_DRAFT_WITH_SEGMENTATION, + })} + />, + ); + + await userEvent.click(screen.getByTestId('saveas-overwrite')); + + await waitFor(() => + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.saveAnalysis', + 'proj-1', + JSON.stringify(emptyAnalysis()), + JSON.stringify(MOCK_SEGMENTATION), + ), + ); + // markSynced receives the exact persisted references so its identity guard can match. + expect(markSynced).toHaveBeenCalledWith( + MOCK_DRAFT_WITH_SEGMENTATION.analysis, + MOCK_SEGMENTATION, + ); + }); + it('reconciles the overwritten project metadata with the draft config', async () => { const draftWithConfig: DraftProject = { sourceProjectId: 'source-proj', diff --git a/src/__tests__/hooks/useDraftProject.test.ts b/src/__tests__/hooks/useDraftProject.test.ts index 04e07030..ff09ade4 100644 --- a/src/__tests__/hooks/useDraftProject.test.ts +++ b/src/__tests__/hooks/useDraftProject.test.ts @@ -194,7 +194,111 @@ describe('useDraftProject', () => { }); }); + describe('autosaveSegmentation', () => { + it('stores the boundary delta on the draft, marks it dirty, and persists it', async () => { + const { result } = await renderLoaded(); + + jest.useFakeTimers(); + const delta = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + act(() => { + result.current.autosaveSegmentation(delta); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + + expect(result.current.dirty).toBe(true); + expect(result.current.getDraftSnapshot()?.segmentation).toEqual(delta); + expect(lastSavedDraft().segmentation).toEqual(delta); + }); + + it('bumps segmentationVersion on every edit, including when the draft was already dirty', async () => { + // The resegmented book is derived from the draft's segmentation, which lives in a ref, and + // `setDirty(true)` bails out of the re-render once the draft is already dirty — so without the + // version bump a second boundary edit would silently fail to update the view (the reported + // "clicking merge/split doesn't re-render"). Assert the counter advances on each call. + const { result } = await renderLoaded(); + + jest.useFakeTimers(); + const versionBefore = result.current.segmentationVersion; + act(() => { + result.current.autosaveSegmentation({ removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + }); + expect(result.current.dirty).toBe(true); + expect(result.current.segmentationVersion).toBe(versionBefore + 1); + + // A second edit while already dirty must still bump the version so the view recomputes. + act(() => { + result.current.autosaveSegmentation({ removedVerseStarts: [], addedStarts: ['GEN 1:1:6'] }); + }); + expect(result.current.segmentationVersion).toBe(versionBefore + 2); + + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + }); + + it('replaces a pending debounced write when called again before it flushes', async () => { + const { result } = await renderLoaded(); + + jest.useFakeTimers(); + act(() => { + result.current.autosaveSegmentation({ removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + }); + // A second call before the debounce fires clears the pending timer and schedules a new write. + act(() => { + result.current.autosaveSegmentation({ removedVerseStarts: [], addedStarts: ['GEN 1:1:6'] }); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + + expect(lastSavedDraft().segmentation).toEqual({ + removedVerseStarts: [], + addedStarts: ['GEN 1:1:6'], + }); + }); + + it('clears the segmentation field when passed undefined (back to default segmentation)', async () => { + // Seed a draft that already has custom boundaries so clearing them is observable. + mockGetDraftResolves( + makeDraft({ segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] } }), + ); + const { result } = await renderLoaded(); + + jest.useFakeTimers(); + act(() => { + result.current.autosaveSegmentation(undefined); + }); + act(() => { + jest.advanceTimersByTime(300); + }); + jest.useRealTimers(); + + expect(result.current.getDraftSnapshot()?.segmentation).toBeUndefined(); + expect(lastSavedDraft().segmentation).toBeUndefined(); + }); + }); + describe('loadFromProject', () => { + it('copies a project segmentation delta into the draft when present', async () => { + const { result } = await renderLoaded(); + + const delta = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: ['GEN 1:1:6'] }; + act(() => { + result.current.loadFromProject({ + analysis: analysisWithToken('tok-open'), + analysisLanguages: ['de'], + segmentation: delta, + }); + }); + + expect(result.current.draft?.segmentation).toEqual(delta); + }); + it('copies analysis, analysis languages, and target, clears dirty, and bumps the version', async () => { const { result } = await renderLoaded(); const versionBefore = result.current.draftVersion; @@ -351,6 +455,24 @@ describe('useDraftProject', () => { expect(result.current.draftVersion).toBe(versionBefore + 1); expect(lastSavedDraft().dirty).toBe(false); }); + + it('clears any custom segment boundaries as part of the clean baseline', async () => { + mockGetDraftResolves( + makeDraft({ + analysis: analysisWithToken('tok-wipe-all'), + dirty: true, + segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, + }), + ); + const { result } = await renderLoaded(); + + act(() => { + result.current.wipeAll(); + }); + + expect(result.current.draft?.segmentation).toBeUndefined(); + expect(lastSavedDraft().segmentation).toBeUndefined(); + }); }); describe('markSynced', () => { @@ -365,7 +487,7 @@ describe('useDraftProject', () => { const versionBefore = result.current.draftVersion; act(() => { - result.current.markSynced(synced); + result.current.markSynced(synced, undefined); }); expect(result.current.dirty).toBe(false); @@ -388,11 +510,55 @@ describe('useDraftProject', () => { // Syncing against the now-stale snapshot must not clear the unsaved-changes flag. act(() => { - result.current.markSynced(savedSnapshot); + result.current.markSynced(savedSnapshot, undefined); }); expect(result.current.dirty).toBe(true); }); + + it('leaves the draft dirty when a segmentation edit landed since the saved snapshot', async () => { + const { result } = await renderLoaded(); + // Dirty the draft with the analysis a Save would capture and persist. + const savedAnalysis = analysisWithToken('tok-saved'); + act(() => { + result.current.autosaveAnalysis(savedAnalysis); + }); + // A boundary edit lands during the save round-trip. It carries the analysis over by + // reference, so only the segmentation identity distinguishes the ref from the snapshot. + act(() => { + result.current.autosaveSegmentation({ removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + }); + expect(result.current.dirty).toBe(true); + + // The snapshot was taken before the boundary edit (default segmentation), so syncing against + // it must not clear the unsaved-changes flag over the un-persisted boundary change. + act(() => { + result.current.markSynced(savedAnalysis, undefined); + }); + + expect(result.current.dirty).toBe(true); + }); + + it('clears dirty when synced with the matching segmentation reference', async () => { + const { result } = await renderLoaded(); + const delta = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + act(() => { + result.current.autosaveSegmentation(delta); + }); + expect(result.current.dirty).toBe(true); + const snapshot = result.current.getDraftSnapshot(); + if (!snapshot) throw new Error('expected the draft to be loaded'); + + // Save persisted exactly what the ref holds, so the sync clears the unsaved-changes flag. + act(() => { + result.current.markSynced(snapshot.analysis, snapshot.segmentation); + }); + + expect(result.current.dirty).toBe(false); + const saved = lastSavedDraft(); + expect(saved.dirty).toBe(false); + expect(saved.segmentation).toEqual(delta); + }); }); describe('getDraftSnapshot', () => { diff --git a/src/__tests__/hooks/useSegmentWindow.test.ts b/src/__tests__/hooks/useSegmentWindow.test.ts index b7135dd5..00ea8a7d 100644 --- a/src/__tests__/hooks/useSegmentWindow.test.ts +++ b/src/__tests__/hooks/useSegmentWindow.test.ts @@ -23,17 +23,18 @@ declare global { * * @param chapter - Chapter number for the segment's refs. * @param verse - Verse number for the segment's refs. + * @param book - Book code for the segment's refs; defaults to `GEN`. * @returns A minimal {@link Segment}. */ -function makeSegment(chapter: number, verse: number): Segment { +function makeSegment(chapter: number, verse: number, book = 'GEN'): Segment { return { - id: `GEN ${chapter}:${verse}`, - startRef: { book: 'GEN', chapter, verse }, - endRef: { book: 'GEN', chapter, verse }, + id: `${book} ${chapter}:${verse}`, + startRef: { book, chapter, verse }, + endRef: { book, chapter, verse }, baselineText: 'word', tokens: [ { - ref: `GEN ${chapter}:${verse}:0`, + ref: `${book} ${chapter}:${verse}:0`, surfaceText: 'word', writingSystem: 'en', type: 'word', @@ -50,13 +51,14 @@ function makeSegment(chapter: number, verse: number): Segment { * * @param chapter1Count - Number of verses in chapter 1. * @param chapter2Count - Number of verses in chapter 2. + * @param book - Book code for the book and its segments; defaults to `GEN`. * @returns A {@link Book} with the combined flat segment list. */ -function makeBook(chapter1Count: number, chapter2Count: number): Book { +function makeBook(chapter1Count: number, chapter2Count: number, book = 'GEN'): Book { const segments: Segment[] = []; - for (let v = 1; v <= chapter1Count; v += 1) segments.push(makeSegment(1, v)); - for (let v = 1; v <= chapter2Count; v += 1) segments.push(makeSegment(2, v)); - return { id: 'GEN', bookRef: 'GEN', textVersion: 'v1', segments }; + for (let v = 1; v <= chapter1Count; v += 1) segments.push(makeSegment(1, v, book)); + for (let v = 1; v <= chapter2Count; v += 1) segments.push(makeSegment(2, v, book)); + return { id: book, bookRef: book, textVersion: 'v1', segments }; } /** @@ -92,13 +94,20 @@ function renderSegmentWindow( const onDisplayContinuousScrollChange = (v: boolean) => displayContinuousScrollReports.push(v); const hook = renderHook< ReturnType, - { b: Book; ref: SerializedVerseRef; focus?: string | undefined; cont?: boolean } + { + b: Book; + ref: SerializedVerseRef; + focus?: string | undefined; + cont?: boolean; + segVersion?: number; + } >( - ({ b, ref, focus, cont }) => { + ({ b, ref, focus, cont, segVersion }) => { const scrollContainerRef = useRef(container); return useSegmentWindow({ book: b, scrRef: ref, + segmentationVersion: segVersion ?? 0, focusedTokenRef: focus, continuousScroll: cont ?? false, scrollContainerRef, @@ -293,6 +302,25 @@ describe('useSegmentWindow', () => { expect(ids).toContain('GEN 2:1'); }); + it('anchors on the segment whose verse range contains the reference', () => { + // Verse 10 is split and its second half merged with verses 11–12; navigating to verse 11 + // must center the window on that containing segment, not fall back to the chapter start. + const segments = [ + ...Array.from({ length: 9 }, (_, i) => makeSegment(1, i + 1)), + { ...makeSegment(1, 10), id: 'GEN 1:10a' }, + { ...makeSegment(1, 10), id: 'GEN 1:10b-12', endRef: { book: 'GEN', chapter: 1, verse: 12 } }, + ...Array.from({ length: 8 }, (_, i) => makeSegment(1, i + 13)), + ]; + const book: Book = { id: 'GEN', bookRef: 'GEN', textVersion: 'v1', segments }; + const { result } = renderSegmentWindow(book, { book: 'GEN', chapterNum: 1, verseNum: 11 }); + + // The merged segment sits at flat index 10, so the centered window runs [2, 19) — anchoring + // at the chapter start instead would have produced [0, 9). + const ids = result.current.windowSegments.map((s) => s.id); + expect(ids).toContain('GEN 1:10b-12'); + expect(ids[0]).toBe('GEN 1:3'); + }); + it('falls back to the first segment of the chapter when no exact verse matches', () => { const book = makeBook(10, 10); const { result } = renderSegmentWindow(book, { book: 'GEN', chapterNum: 2, verseNum: 999 }); @@ -578,10 +606,10 @@ describe('useSegmentWindow', () => { expect(result.current.windowSegments.map((s) => s.id)).toContain('GEN 1:50'); }); - it('fades and recenters when the segments identity changes at the same anchor index', () => { - // A book swap (or a re-tokenized book) hands the hook a new `segments` array whose anchor can - // resolve to the same index as before; the identity check must still detect the change and - // recenter rather than leaving the window on stale segment objects. + it('fades and recenters when a book swap changes the segments identity at the same anchor index', () => { + // A book swap hands the hook a new `segments` array whose anchor can resolve to the same index + // as before; the identity check must still detect the change and recenter rather than leaving + // the window on stale segment objects. const book = makeBook(10, 0); const { result, rerender } = renderSegmentWindow(book, { book: 'GEN', @@ -590,11 +618,94 @@ describe('useSegmentWindow', () => { }); expect(result.current.isFaded).toBe(false); - act(() => rerender({ b: makeBook(10, 0), ref: { book: 'GEN', chapterNum: 1, verseNum: 1 } })); + act(() => + rerender({ b: makeBook(10, 0, 'EXO'), ref: { book: 'EXO', chapterNum: 1, verseNum: 1 } }), + ); + + expect(result.current.isFaded).toBe(true); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.isFaded).toBe(false); + }); + + it('redraws in place without fading on a boundary edit (segments change with a version bump)', () => { + // A boundary edit (merge/split) re-segments the loaded book: new `segments` identity carrying a + // `segmentationVersion` bump. The new slice already rendered in the same commit, so a fade + // afterwards would flash content the user is looking at and snap it away from the point they + // clicked — the window must leave the redraw alone. + const book = makeBook(10, 0); + const scrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + const { result, rerender } = renderSegmentWindow(book, scrRef); + const editedBook = makeBook(9, 0); + + act(() => rerender({ b: editedBook, ref: scrRef, segVersion: 1 })); + + expect(result.current.isFaded).toBe(false); + // The window slices the edited book's segments immediately — no midpoint swap to wait for. + expect(result.current.windowSegments[0]).toBe(editedBook.segments[0]); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.isFaded).toBe(false); + }); + + it('does not fade on a boundary edit even when the anchor verse changes', () => { + // A merge that absorbs the active verse's segment start (GEN 1:2 merged into GEN 1:1) makes the + // loader re-resolve the scrRef to the surviving segment's verse in the same commit. The verse + // changed, but the version bump identifies this as a boundary edit, not a navigation — no fade. + const book = makeBook(10, 0); + const { result, rerender } = renderSegmentWindow(book, { + book: 'GEN', + chapterNum: 1, + verseNum: 2, + }); + const editedBook = makeBook(9, 0); + + act(() => + rerender({ b: editedBook, ref: { book: 'GEN', chapterNum: 1, verseNum: 1 }, segVersion: 1 }), + ); + + expect(result.current.isFaded).toBe(false); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + expect(result.current.isFaded).toBe(false); + }); + + it('syncs the display refs immediately on a boundary edit that changes the anchor verse', () => { + // The redraw stands without a recenter midpoint, so the display refs would otherwise be + // stranded on the pre-edit verse/token; the boundary-edit branch must sync them in place. + const book = makeBook(10, 0); + const { result, rerender } = renderSegmentWindow( + book, + { book: 'GEN', chapterNum: 1, verseNum: 2 }, + 'tok-old', + ); + const editedBook = makeBook(9, 0); + + act(() => + rerender({ + b: editedBook, + ref: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + focus: 'tok-new', + segVersion: 1, + }), + ); + + expect(result.current.displayScrRef.verseNum).toBe(1); + expect(result.current.displayFocusedTokenRef).toBe('tok-new'); + }); + + it('fades and recenters when the segments change without a version bump at the same anchor verse', () => { + // A re-tokenization of the loaded book hands the hook a new `segments` identity with the same + // `segmentationVersion` and can keep the anchor verse. The mounted index range no longer + // matches the content, so this must recenter with the fade like any external change. + const book = makeBook(10, 0); + const scrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + const { result, rerender } = renderSegmentWindow(book, scrRef); + const retokenizedBook = makeBook(10, 0); + + act(() => rerender({ b: retokenizedBook, ref: scrRef })); expect(result.current.isFaded).toBe(true); act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); expect(result.current.isFaded).toBe(false); + expect(result.current.windowSegments[0]).toBe(retokenizedBook.segments[0]); }); it('lags displayScrRef through the fade so the highlight moves only with the window swap', () => { diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 0fc3fd64..b3349cb6 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -170,9 +170,9 @@ const getGetProjectHandler = () => /** Activates the extension and returns the `interlinearizer.saveAnalysis` handler. */ const getSaveAnalysisHandler = () => - activateAndGetHandler<(id: string, analysisJson: string) => Promise>( - 'interlinearizer.saveAnalysis', - ); + activateAndGetHandler< + (id: string, analysisJson: string, segmentationJson?: string) => Promise + >('interlinearizer.saveAnalysis'); /** Activates the extension and returns the `interlinearizer.getDraft` handler. */ const getGetDraftHandler = () => @@ -910,7 +910,13 @@ describe('main', () => { await handler('proj-id', JSON.stringify(stubAnalysis)); - expect(mockUpdateAnalysis).toHaveBeenCalledWith(expect.anything(), 'proj-id', stubAnalysis); + // No segmentationJson passed ⇒ the 4th arg is undefined (leave stored boundaries unchanged). + expect(mockUpdateAnalysis).toHaveBeenCalledWith( + expect.anything(), + 'proj-id', + stubAnalysis, + undefined, + ); }); it('logs the error, sends an error notification, and rethrows when storage throws', async () => { @@ -956,6 +962,46 @@ describe('main', () => { ); expect(mockUpdateAnalysis).not.toHaveBeenCalled(); }); + + it('passes a parsed segmentation delta through to updateAnalysis', async () => { + mockUpdateAnalysis.mockResolvedValue(undefined); + const handler = await getSaveAnalysisHandler(); + const segmentation = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + + await handler('proj-id', JSON.stringify(stubAnalysis), JSON.stringify(segmentation)); + + expect(mockUpdateAnalysis).toHaveBeenCalledWith( + expect.anything(), + 'proj-id', + stubAnalysis, + segmentation, + ); + }); + + it('passes null through to updateAnalysis to clear boundaries when segmentationJson is "null"', async () => { + mockUpdateAnalysis.mockResolvedValue(undefined); + const handler = await getSaveAnalysisHandler(); + + await handler('proj-id', JSON.stringify(stubAnalysis), 'null'); + + // eslint-disable-next-line no-null/no-null -- asserting the clear-boundaries sentinel is forwarded + expect(mockUpdateAnalysis).toHaveBeenCalledWith( + expect.anything(), + 'proj-id', + stubAnalysis, + // eslint-disable-next-line no-null/no-null -- asserting the clear-boundaries sentinel is forwarded + null, + ); + }); + + it('rethrows when segmentationJson does not conform to SegmentationDelta', async () => { + const handler = await getSaveAnalysisHandler(); + + await expect( + handler('proj-id', JSON.stringify(stubAnalysis), JSON.stringify({ bogus: true })), + ).rejects.toThrow(TypeError); + expect(mockUpdateAnalysis).not.toHaveBeenCalled(); + }); }); describe('interlinearizer.getDraft command', () => { diff --git a/src/__tests__/parsers/papi/resegmentBook.test.ts b/src/__tests__/parsers/papi/resegmentBook.test.ts new file mode 100644 index 00000000..76ade277 --- /dev/null +++ b/src/__tests__/parsers/papi/resegmentBook.test.ts @@ -0,0 +1,103 @@ +/** @file Unit tests for {@link resegmentBook}. */ +/// + +import type { Book } from 'interlinearizer'; +import { tokenizeBook } from 'parsers/papi/bookTokenizer'; +import { resegmentBook } from 'parsers/papi/resegmentBook'; + +/** + * Builds a verse-tokenized GEN book from the given verses. + * + * @param verses - Verse SID + text pairs. + * @returns The tokenized book. + */ +function makeBook(verses: { sid: string; text: string }[]): Book { + return tokenizeBook({ bookCode: 'GEN', writingSystem: 'en', contentHash: 'h', verses }); +} + +const BOOK = makeBook([ + { sid: 'GEN 1:1', text: 'Alpha beta.' }, + { sid: 'GEN 1:2', text: 'Gamma delta.' }, + { sid: 'GEN 1:3', text: 'Epsilon.' }, +]); + +/** Asserts the segment-baseline/offset invariant for every token of every segment. */ +function expectInvariant(book: Book): void { + book.segments.forEach((seg) => { + seg.tokens.forEach((t) => { + expect(seg.baselineText.slice(t.charStart, t.charEnd)).toBe(t.surfaceText); + }); + }); +} + +describe('resegmentBook', () => { + it('returns the same book reference for an undefined delta', () => { + expect(resegmentBook(BOOK, undefined)).toBe(BOOK); + }); + + it('returns the same book reference for an empty delta', () => { + expect(resegmentBook(BOOK, { removedVerseStarts: [], addedStarts: [] })).toBe(BOOK); + }); + + it('reuses untouched verse Segment objects by reference when a delta is active elsewhere', () => { + // Merge verses 1+2; verse 3 is untouched and should be the same object. + const result = resegmentBook(BOOK, { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + expect(result.segments[1]).toBe(BOOK.segments[2]); + }); + + it('merges two verses into one segment with concatenated baseline and shifted offsets', () => { + const result = resegmentBook(BOOK, { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + expect(result.segments).toHaveLength(2); + const merged = result.segments[0]; + expect(merged.baselineText).toBe('Alpha beta. Gamma delta.'); + // Token refs are preserved unchanged across the merge. + expect(merged.tokens.map((t) => t.ref)).toEqual([ + 'GEN 1:1:0', + 'GEN 1:1:6', + 'GEN 1:1:10', + 'GEN 1:2:0', + 'GEN 1:2:6', + 'GEN 1:2:11', + ]); + expectInvariant(result); + }); + + it('keeps the leading verse SID as the merged segment id', () => { + const result = resegmentBook(BOOK, { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + expect(result.segments[0].id).toBe('GEN 1:1'); + }); + + it('spans the merged range in startRef/endRef', () => { + const result = resegmentBook(BOOK, { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }); + expect(result.segments[0].startRef).toEqual({ book: 'GEN', chapter: 1, verse: 1 }); + expect(result.segments[0].endRef).toEqual({ book: 'GEN', chapter: 1, verse: 2 }); + }); + + it('splits a verse before a mid-verse token', () => { + // Split verse 1 before "beta" (charStart 6). + const result = resegmentBook(BOOK, { removedVerseStarts: [], addedStarts: ['GEN 1:1:6'] }); + expect(result.segments).toHaveLength(4); + const [firstHalf, secondHalf] = result.segments; + expect(firstHalf.id).toBe('GEN 1:1'); + expect(firstHalf.tokens.map((t) => t.ref)).toEqual(['GEN 1:1:0']); + // The second half begins mid-verse, so it takes its first token's ref as a fresh id. + expect(secondHalf.id).toBe('GEN 1:1:6'); + expect(secondHalf.tokens.map((t) => t.ref)).toEqual(['GEN 1:1:6', 'GEN 1:1:10']); + expectInvariant(result); + }); + + it('carries a sub-verse charIndex on a split piece that begins mid-verse', () => { + const result = resegmentBook(BOOK, { removedVerseStarts: [], addedStarts: ['GEN 1:1:6'] }); + expect(result.segments[1].startRef).toEqual({ + book: 'GEN', + chapter: 1, + verse: 1, + charIndex: 6, + }); + }); + + it('ignores a drifted (nonexistent) anchor and yields the default grouping', () => { + const result = resegmentBook(BOOK, { removedVerseStarts: ['GEN 9:9:9'], addedStarts: [] }); + expect(result.segments.map((s) => s.id)).toEqual(['GEN 1:1', 'GEN 1:2', 'GEN 1:3']); + }); +}); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index 713a611c..98e537e5 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -454,6 +454,45 @@ describe('projectStorage', () => { await expect(updateAnalysis(token, 'proj-id', newAnalysis)).rejects.toThrow('disk full'); }); + + it('writes a provided segmentation delta onto the project', async () => { + __mockReadUserData.mockResolvedValue(JSON.stringify(storedProject)); + const segmentation = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + + const result = await updateAnalysis(token, 'proj-id', newAnalysis, segmentation); + + expect(result).toMatchObject({ analysis: newAnalysis, segmentation }); + expect(__mockWriteUserData).toHaveBeenCalledWith( + token, + 'project:proj-id', + JSON.stringify({ ...storedProject, analysis: newAnalysis, segmentation }), + ); + }); + + it('clears stored boundaries when segmentation is null', async () => { + const projectWithBoundaries = { + ...storedProject, + segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, + }; + __mockReadUserData.mockResolvedValue(JSON.stringify(projectWithBoundaries)); + + // eslint-disable-next-line no-null/no-null -- explicit "clear boundaries" sentinel under test + const result = await updateAnalysis(token, 'proj-id', newAnalysis, null); + + expect(result && 'segmentation' in result).toBe(false); + }); + + it('leaves existing boundaries unchanged when segmentation is undefined', async () => { + const projectWithBoundaries = { + ...storedProject, + segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, + }; + __mockReadUserData.mockResolvedValue(JSON.stringify(projectWithBoundaries)); + + const result = await updateAnalysis(token, 'proj-id', newAnalysis); + + expect(result).toMatchObject({ segmentation: projectWithBoundaries.segmentation }); + }); }); describe('getProjectsForSource', () => { diff --git a/src/__tests__/utils/segment-labels.test.ts b/src/__tests__/utils/segment-labels.test.ts new file mode 100644 index 00000000..2eec2a84 --- /dev/null +++ b/src/__tests__/utils/segment-labels.test.ts @@ -0,0 +1,133 @@ +/** @file Unit tests for utils/segment-labels.ts. */ +/// + +import type { Segment } from 'interlinearizer'; +import { buildSegmentLabels } from '../../utils/segment-labels'; + +/** + * Builds a minimal token-less {@link Segment} spanning the given verse coordinates. Label derivation + * only reads `id`, `startRef`, and `endRef`, so tokens and baseline text stay empty. + * + * @param id - The segment id. + * @param startChapter - Chapter of the segment's start. + * @param startVerse - Verse of the segment's start. + * @param endChapter - Chapter of the segment's end; defaults to `startChapter`. + * @param endVerse - Verse of the segment's end; defaults to `startVerse`. + * @returns The assembled segment. + */ +function makeSegment( + id: string, + startChapter: number, + startVerse: number, + endChapter: number = startChapter, + endVerse: number = startVerse, +): Segment { + return { + id, + startRef: { book: 'GEN', chapter: startChapter, verse: startVerse }, + endRef: { book: 'GEN', chapter: endChapter, verse: endVerse }, + baselineText: '', + tokens: [], + }; +} + +describe('buildSegmentLabels', () => { + it('labels default verse segments with their bare verse numbers', () => { + const labels = buildSegmentLabels([ + makeSegment('s1', 1, 1), + makeSegment('s2', 1, 2), + makeSegment('s3', 1, 3), + ]); + + expect(labels.get('s1')).toBe('1'); + expect(labels.get('s2')).toBe('2'); + expect(labels.get('s3')).toBe('3'); + }); + + it('labels a verse-0 superscription segment as 0', () => { + const labels = buildSegmentLabels([makeSegment('s0', 1, 0), makeSegment('s1', 1, 1)]); + + expect(labels.get('s0')).toBe('0'); + expect(labels.get('s1')).toBe('1'); + }); + + it('shows a merged segment as the contained verse range', () => { + const labels = buildSegmentLabels([ + makeSegment('s1', 1, 1), + makeSegment('merged', 1, 2, 1, 3), + makeSegment('s4', 1, 4), + ]); + + expect(labels.get('merged')).toBe('2–3'); + // Neighboring whole-verse segments keep their bare numbers. + expect(labels.get('s4')).toBe('4'); + }); + + it('letters the portions of a split verse in document order', () => { + const labels = buildSegmentLabels([ + makeSegment('s1', 1, 1), + makeSegment('split-a', 1, 2), + makeSegment('split-b', 1, 2), + makeSegment('s3', 1, 3), + ]); + + expect(labels.get('split-a')).toBe('2a'); + expect(labels.get('split-b')).toBe('2b'); + // The verse after the split is unaffected. + expect(labels.get('s3')).toBe('3'); + }); + + it('letters only the split end of a range when a split portion is merged with following verses', () => { + // Split verse 1 into three portions, the third merged with verse 2: 1a, 1b, 1c–2, 3. + const labels = buildSegmentLabels([ + makeSegment('p1', 1, 1), + makeSegment('p2', 1, 1), + makeSegment('p3', 1, 1, 1, 2), + makeSegment('s3', 1, 3), + ]); + + expect(labels.get('p1')).toBe('1a'); + expect(labels.get('p2')).toBe('1b'); + expect(labels.get('p3')).toBe('1c–2'); + expect(labels.get('s3')).toBe('3'); + }); + + it('letters a range end when the segment ends mid-verse', () => { + // Verse 1 merged with the first portion of a split verse 2: 1–2a, then 2b. + const labels = buildSegmentLabels([makeSegment('head', 1, 1, 1, 2), makeSegment('tail', 1, 2)]); + + expect(labels.get('head')).toBe('1–2a'); + expect(labels.get('tail')).toBe('2b'); + }); + + it('qualifies the range end with its chapter when a segment crosses a chapter boundary', () => { + const labels = buildSegmentLabels([ + makeSegment('c1s1', 1, 1), + makeSegment('crossing', 1, 2, 2, 1), + makeSegment('c2s2', 2, 2), + ]); + + expect(labels.get('crossing')).toBe('2–2:1'); + expect(labels.get('c2s2')).toBe('2'); + }); + + it('letters a chapter-crossing end when its verse is split', () => { + const labels = buildSegmentLabels([ + makeSegment('crossing', 1, 29, 2, 1), + makeSegment('tail', 2, 1), + ]); + + expect(labels.get('crossing')).toBe('29–2:1a'); + expect(labels.get('tail')).toBe('1b'); + }); + + it('continues lettering bijectively past z for a verse split into more than 26 portions', () => { + const portions = Array.from({ length: 28 }, (_, i) => makeSegment(`p${i}`, 1, 1)); + const labels = buildSegmentLabels(portions); + + expect(labels.get('p0')).toBe('1a'); + expect(labels.get('p25')).toBe('1z'); + expect(labels.get('p26')).toBe('1aa'); + expect(labels.get('p27')).toBe('1ab'); + }); +}); diff --git a/src/__tests__/utils/segmentation.test.ts b/src/__tests__/utils/segmentation.test.ts new file mode 100644 index 00000000..96141ea8 --- /dev/null +++ b/src/__tests__/utils/segmentation.test.ts @@ -0,0 +1,257 @@ +/** @file Unit tests for the pure segmentation-delta transforms. */ +/// + +import type { Book, SegmentationDelta } from 'interlinearizer'; +import { tokenizeBook } from 'parsers/papi/bookTokenizer'; +import { + addBoundaryBefore, + defaultVerseStarts, + effectiveStarts, + isDefaultSegmentation, + mergeSegments, + moveBoundary, + removeBoundaryAt, + splitSegmentBefore, +} from '../../utils/segmentation'; + +/** + * Builds a verse-tokenized GEN book from the given verses for use as the `verseBook` argument. + * + * @param verses - Verse SID + text pairs. + * @returns The tokenized book. + */ +function makeBook(verses: { sid: string; text: string }[]): Book { + return tokenizeBook({ bookCode: 'GEN', writingSystem: 'en', contentHash: 'h', verses }); +} + +/** A three-verse fixture: "Alpha beta." / "Gamma delta." / "Epsilon." */ +const THREE_VERSES = makeBook([ + { sid: 'GEN 1:1', text: 'Alpha beta.' }, + { sid: 'GEN 1:2', text: 'Gamma delta.' }, + { sid: 'GEN 1:3', text: 'Epsilon.' }, +]); + +// First token refs of each verse (charStart 0): "GEN 1:1:0", "GEN 1:2:0", "GEN 1:3:0". +const V1_START = 'GEN 1:1:0'; +const V2_START = 'GEN 1:2:0'; +const V3_START = 'GEN 1:3:0'; +// Second word of verse 1 ("beta" at charStart 6). +const V1_BETA = 'GEN 1:1:6'; + +/** + * A fixture with a mid-book verse-0 superscription: GEN 1:1, then GEN 2:0 (the superscription), + * then GEN 2:1. Pins the decision that verse 0 is an ordinary segment for boundary editing — no + * guard makes its boundaries special (only the always-present book-first lock applies). + */ +const MID_VERSE_ZERO = makeBook([ + { sid: 'GEN 1:1', text: 'Alpha beta.' }, + { sid: 'GEN 2:0', text: 'Sup tee.' }, + { sid: 'GEN 2:1', text: 'Gamma.' }, +]); +// Verse-0 start, an interior verse-0 word ("tee" at charStart 4), and the start of the verse right +// after verse 0. +const VZ0_START = 'GEN 2:0:0'; +const VZ0_INTERIOR = 'GEN 2:0:4'; +const VZ_NEXT_START = 'GEN 2:1:0'; + +describe('defaultVerseStarts', () => { + it('returns the first-token ref of every verse', () => { + expect(defaultVerseStarts(THREE_VERSES)).toEqual(new Set([V1_START, V2_START, V3_START])); + }); + + it('skips verses with no tokens', () => { + const book = makeBook([ + { sid: 'GEN 1:1', text: ' ' }, + { sid: 'GEN 1:2', text: 'Word.' }, + ]); + expect(defaultVerseStarts(book)).toEqual(new Set(['GEN 1:2:0'])); + }); +}); + +describe('isDefaultSegmentation', () => { + it('is true for undefined', () => { + expect(isDefaultSegmentation(undefined)).toBe(true); + }); + + it('is true for empty arrays', () => { + expect(isDefaultSegmentation({ removedVerseStarts: [], addedStarts: [] })).toBe(true); + }); + + it('is false when a boundary is removed', () => { + expect(isDefaultSegmentation({ removedVerseStarts: [V2_START], addedStarts: [] })).toBe(false); + }); + + it('is false when a boundary is added', () => { + expect(isDefaultSegmentation({ removedVerseStarts: [], addedStarts: [V1_BETA] })).toBe(false); + }); +}); + +describe('effectiveStarts', () => { + it('returns all default verse starts for the default segmentation', () => { + expect(effectiveStarts(THREE_VERSES, undefined)).toEqual( + new Set([V1_START, V2_START, V3_START]), + ); + }); + + it('drops a removed verse start (merge)', () => { + const starts = effectiveStarts(THREE_VERSES, { + removedVerseStarts: [V2_START], + addedStarts: [], + }); + expect(starts).toEqual(new Set([V1_START, V3_START])); + }); + + it('adds a split start', () => { + const starts = effectiveStarts(THREE_VERSES, { + removedVerseStarts: [], + addedStarts: [V1_BETA], + }); + expect(starts).toEqual(new Set([V1_START, V1_BETA, V2_START, V3_START])); + }); + + it('ignores an added start whose token no longer exists (drift)', () => { + const starts = effectiveStarts(THREE_VERSES, { + removedVerseStarts: [], + addedStarts: ['GEN 9:9:9'], + }); + expect(starts).toEqual(new Set([V1_START, V2_START, V3_START])); + }); + + it('always keeps the book-first token as a start even if asked to remove it', () => { + const starts = effectiveStarts(THREE_VERSES, { + removedVerseStarts: [V1_START], + addedStarts: [], + }); + expect(starts.has(V1_START)).toBe(true); + }); +}); + +describe('addBoundaryBefore', () => { + it('records a mid-verse split as an added start', () => { + expect(addBoundaryBefore(THREE_VERSES, undefined, V1_BETA)).toEqual({ + removedVerseStarts: [], + addedStarts: [V1_BETA], + }); + }); + + it('un-merges a default verse start by dropping it from removedVerseStarts', () => { + const merged: SegmentationDelta = { removedVerseStarts: [V2_START], addedStarts: [] }; + expect(addBoundaryBefore(THREE_VERSES, merged, V2_START)).toEqual({ + removedVerseStarts: [], + addedStarts: [], + }); + }); + + it('is idempotent on an already-added start', () => { + const once = addBoundaryBefore(THREE_VERSES, undefined, V1_BETA); + expect(addBoundaryBefore(THREE_VERSES, once, V1_BETA)).toEqual(once); + }); + + it('splits inside a verse-0 superscription like any other verse', () => { + expect(addBoundaryBefore(MID_VERSE_ZERO, undefined, VZ0_INTERIOR)).toEqual({ + removedVerseStarts: [], + addedStarts: [VZ0_INTERIOR], + }); + }); +}); + +describe('removeBoundaryAt', () => { + it('records a default verse start as removed (merge)', () => { + expect(removeBoundaryAt(THREE_VERSES, undefined, V2_START)).toEqual({ + removedVerseStarts: [V2_START], + addedStarts: [], + }); + }); + + it('drops an added split rather than recording a removal', () => { + const split: SegmentationDelta = { removedVerseStarts: [], addedStarts: [V1_BETA] }; + expect(removeBoundaryAt(THREE_VERSES, split, V1_BETA)).toEqual({ + removedVerseStarts: [], + addedStarts: [], + }); + }); + + it('is a no-op for the book-first token', () => { + expect(removeBoundaryAt(THREE_VERSES, undefined, V1_START)).toEqual({ + removedVerseStarts: [], + addedStarts: [], + }); + }); + + it('merges a verse-0 superscription into the previous segment like any verse start', () => { + expect(removeBoundaryAt(MID_VERSE_ZERO, undefined, VZ0_START)).toEqual({ + removedVerseStarts: [VZ0_START], + addedStarts: [], + }); + }); + + it('merges the verse after a verse-0 superscription into it like any verse start', () => { + expect(removeBoundaryAt(MID_VERSE_ZERO, undefined, VZ_NEXT_START)).toEqual({ + removedVerseStarts: [VZ_NEXT_START], + addedStarts: [], + }); + }); +}); + +describe('moveBoundary', () => { + it('removes the old start and adds the new one', () => { + expect(moveBoundary(THREE_VERSES, undefined, V2_START, V1_BETA)).toEqual({ + removedVerseStarts: [V2_START], + addedStarts: [V1_BETA], + }); + }); + + it('moves a boundary across a verse-0 superscription like any other segment', () => { + expect(moveBoundary(MID_VERSE_ZERO, undefined, VZ_NEXT_START, VZ0_INTERIOR)).toEqual({ + removedVerseStarts: [VZ_NEXT_START], + addedStarts: [VZ0_INTERIOR], + }); + }); +}); + +describe('mergeSegments / splitSegmentBefore aliases', () => { + it('mergeSegments removes the second segment start', () => { + expect(mergeSegments(THREE_VERSES, undefined, V2_START)).toEqual( + removeBoundaryAt(THREE_VERSES, undefined, V2_START), + ); + }); + + it('splitSegmentBefore adds a start', () => { + expect(splitSegmentBefore(THREE_VERSES, undefined, V1_BETA)).toEqual( + addBoundaryBefore(THREE_VERSES, undefined, V1_BETA), + ); + }); +}); + +describe('normalization', () => { + it('dedupes and sorts removed/added arrays by document order', () => { + const messy: SegmentationDelta = { + removedVerseStarts: [V3_START, V2_START, V2_START], + addedStarts: [], + }; + // Re-adding V1_BETA twice plus the messy removals exercises dedupe + sort. + const result = addBoundaryBefore( + THREE_VERSES, + addBoundaryBefore(THREE_VERSES, messy, V1_BETA), + V1_BETA, + ); + expect(result).toEqual({ removedVerseStarts: [V2_START, V3_START], addedStarts: [V1_BETA] }); + }); + + it('strips a removed ref that is not a default verse start', () => { + const bogus: SegmentationDelta = { removedVerseStarts: [V1_BETA], addedStarts: [] }; + // V1_BETA is mid-verse, not a default start, so it is not a valid removal. + expect(removeBoundaryAt(THREE_VERSES, bogus, V3_START)).toEqual({ + removedVerseStarts: [V3_START], + addedStarts: [], + }); + }); + + it('strips an added ref that is actually a default verse start', () => { + const bogus: SegmentationDelta = { removedVerseStarts: [], addedStarts: [V2_START] }; + expect(addBoundaryBefore(THREE_VERSES, bogus, V1_BETA)).toEqual({ + removedVerseStarts: [], + addedStarts: [V1_BETA], + }); + }); +}); diff --git a/src/__tests__/utils/token-layout.test.ts b/src/__tests__/utils/token-layout.test.ts index 318f37f4..64fe0f76 100644 --- a/src/__tests__/utils/token-layout.test.ts +++ b/src/__tests__/utils/token-layout.test.ts @@ -148,6 +148,53 @@ describe('resolveSlotFocus', () => { expect(result.focusedPhraseLink).toBe(link); expect(result.focusedFreeToken).toBe(freeToken); }); + + /** Segment document order for the adjacent-edge tests: seg-0 < seg-1 < seg-2. */ + const order = new Map([ + ['seg-0', 0], + ['seg-1', 1], + ['seg-2', 2], + ]); + + it('marks isAdjacentEdgeOfFocus true when the focused segment borders the next one', () => { + const result = resolveSlotFocus('seg-1', 'seg-2', focusWithSegment('seg-1'), true, order); + expect(result.isAdjacentEdgeOfFocus).toBe(true); + }); + + it('marks isAdjacentEdgeOfFocus true when the focused segment borders the previous one', () => { + const result = resolveSlotFocus('seg-0', 'seg-1', focusWithSegment('seg-1'), false, order); + expect(result.isAdjacentEdgeOfFocus).toBe(true); + }); + + it('marks isAdjacentEdgeOfFocus false within one segment', () => { + const result = resolveSlotFocus('seg-1', 'seg-1', focusWithSegment('seg-1'), true, order); + expect(result.isAdjacentEdgeOfFocus).toBe(false); + }); + + it('marks isAdjacentEdgeOfFocus false when nothing is focused', () => { + const result = resolveSlotFocus('seg-1', 'seg-2', focusWithSegment(undefined), true, order); + expect(result.isAdjacentEdgeOfFocus).toBe(false); + }); + + it('marks isAdjacentEdgeOfFocus false when neither neighbor is the focused segment', () => { + const result = resolveSlotFocus('seg-0', 'seg-2', focusWithSegment('seg-1'), true, order); + expect(result.isAdjacentEdgeOfFocus).toBe(false); + }); + + it('marks isAdjacentEdgeOfFocus false when the two segments are not adjacent', () => { + const result = resolveSlotFocus('seg-0', 'seg-2', focusWithSegment('seg-0'), true, order); + expect(result.isAdjacentEdgeOfFocus).toBe(false); + }); + + it('marks isAdjacentEdgeOfFocus false when a leading slot has no previous segment', () => { + const result = resolveSlotFocus(undefined, 'seg-1', focusWithSegment('seg-1'), false, order); + expect(result.isAdjacentEdgeOfFocus).toBe(false); + }); + + it('marks isAdjacentEdgeOfFocus false when segment order is unknown (default empty map)', () => { + const result = resolveSlotFocus('seg-1', 'seg-2', focusWithSegment('seg-1'), true); + expect(result.isAdjacentEdgeOfFocus).toBe(false); + }); }); // --------------------------------------------------------------------------- @@ -159,6 +206,7 @@ describe('NO_SLOT_FOCUS', () => { expect(NO_SLOT_FOCUS).toEqual({ focusedSideIsPrev: undefined, isSameSegmentAsFocus: false, + isAdjacentEdgeOfFocus: false, focusedPhraseLink: undefined, focusedFreeToken: undefined, }); diff --git a/src/__tests__/utils/verse-ref.test.ts b/src/__tests__/utils/verse-ref.test.ts new file mode 100644 index 00000000..30bef88d --- /dev/null +++ b/src/__tests__/utils/verse-ref.test.ts @@ -0,0 +1,85 @@ +/** @file Unit tests for utils/verse-ref.ts. */ +/// + +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import type { Segment } from 'interlinearizer'; +import { segmentContainsVerse } from '../../utils/verse-ref'; + +/** + * Builds a minimal token-less {@link Segment} spanning the given verse coordinates. Containment only + * reads `startRef` and `endRef`, so tokens and baseline text stay empty. + * + * @param startChapter - Chapter of the segment's start. + * @param startVerse - Verse of the segment's start. + * @param endChapter - Chapter of the segment's end; defaults to `startChapter`. + * @param endVerse - Verse of the segment's end; defaults to `startVerse`. + * @returns The assembled segment. + */ +function makeSegment( + startChapter: number, + startVerse: number, + endChapter: number = startChapter, + endVerse: number = startVerse, +): Segment { + return { + id: 'seg', + startRef: { book: 'GEN', chapter: startChapter, verse: startVerse }, + endRef: { book: 'GEN', chapter: endChapter, verse: endVerse }, + baselineText: '', + tokens: [], + }; +} + +/** + * Builds a `SerializedVerseRef` in the test book. + * + * @param chapterNum - The chapter number. + * @param verseNum - The verse number. + * @param book - The book id; defaults to the segments' book. + * @returns The assembled reference. + */ +function makeRef(chapterNum: number, verseNum: number, book: string = 'GEN'): SerializedVerseRef { + return { book, chapterNum, verseNum }; +} + +describe('segmentContainsVerse', () => { + it('contains the verse a single-verse segment names', () => { + expect(segmentContainsVerse(makeSegment(1, 2), makeRef(1, 2))).toBe(true); + }); + + it('does not contain a different verse of the same chapter', () => { + expect(segmentContainsVerse(makeSegment(1, 2), makeRef(1, 3))).toBe(false); + }); + + it('contains an interior verse of a merged multi-verse segment', () => { + expect(segmentContainsVerse(makeSegment(1, 1, 1, 3), makeRef(1, 2))).toBe(true); + }); + + it('contains both end verses of a multi-verse segment inclusively', () => { + const seg = makeSegment(1, 2, 1, 4); + expect(segmentContainsVerse(seg, makeRef(1, 2))).toBe(true); + expect(segmentContainsVerse(seg, makeRef(1, 4))).toBe(true); + }); + + it('does not contain verses outside the range', () => { + const seg = makeSegment(1, 2, 1, 4); + expect(segmentContainsVerse(seg, makeRef(1, 1))).toBe(false); + expect(segmentContainsVerse(seg, makeRef(1, 5))).toBe(false); + }); + + it('contains verses across a chapter-crossing range', () => { + const seg = makeSegment(1, 29, 2, 2); + expect(segmentContainsVerse(seg, makeRef(1, 30))).toBe(true); + expect(segmentContainsVerse(seg, makeRef(2, 1))).toBe(true); + }); + + it('does not contain verses beyond a chapter-crossing range', () => { + const seg = makeSegment(1, 29, 2, 2); + expect(segmentContainsVerse(seg, makeRef(1, 28))).toBe(false); + expect(segmentContainsVerse(seg, makeRef(2, 3))).toBe(false); + }); + + it('never contains a verse from another book', () => { + expect(segmentContainsVerse(makeSegment(1, 1, 1, 3), makeRef(1, 2, 'EXO'))).toBe(false); + }); +}); diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx index dfa4239e..4fb3b04e 100644 --- a/src/components/AnalysisStore.tsx +++ b/src/components/AnalysisStore.tsx @@ -544,6 +544,22 @@ export function usePhraseLinkByIdMap(): Map { return useSelector((state: AnalysisRootState) => selectPhraseLinkByAnalysisId(state.analysis)); } +/** + * Returns a stable getter for the current phrase-link-by-id map, for event-time reads that must not + * subscribe the caller to store updates — e.g. the segmentation dispatch's force-break, which reads + * the phrases only when a boundary edit fires and would otherwise re-render the whole view tree on + * every phrase change. + * + * @returns A stable function returning the phrase-link-by-id map at call time. + * @throws When called outside an {@link AnalysisStoreProvider}. + */ +export function usePhraseLinkByIdGetter(): () => Map { + useRequiredCallbacks('usePhraseLinkByIdGetter'); + const store = useStore(); + + return useCallback(() => selectPhraseLinkByAnalysisId(store.getState().analysis), [store]); +} + /** * Returns the approved `PhraseAnalysisLink` whose token list contains `tokenRef`, or `undefined` * when the token is not part of any phrase. Re-renders only when the phrase membership of this diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index a230e212..8f1a1b9a 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -298,6 +298,28 @@ export default function ContinuousView({ }); }, []); + /** + * Holds the group at `groupIndex` centered by re-centering it each animation frame for + * {@link LINK_SLOT_TRANSITION_MS} — the shared clock of the link-slot fade, whose async layout + * settling (arcs, morpheme rows) is what the hold outlasts. Used after an instant jump's reveal + * and after the committed-active-segment flip, so both holds run one loop implementation on one + * clock. + * + * @param groupIndex - Index of the group to keep centered. + * @returns A cancel function that stops the loop; call it from the owning effect's cleanup. + */ + const holdCentered = useCallback( + (groupIndex: number) => { + const deadline = performance.now() + LINK_SLOT_TRANSITION_MS; + let rafId = requestAnimationFrame(function holdFrame() { + centerGroup(groupIndex, 'auto'); + if (performance.now() < deadline) rafId = requestAnimationFrame(holdFrame); + }); + return () => cancelAnimationFrame(rafId); + }, + [centerGroup], + ); + /** Ref to the token-strip row; the content row and mouse-leave target. */ // eslint-disable-next-line no-null/no-null const stripRowRef = useRef(null); @@ -338,6 +360,28 @@ export default function ContinuousView({ setCommittedActiveSegmentId(targetActiveSegmentIdRef.current); }, [targetActiveSegmentIdRef]); + /** + * `focusedTokenRef` last seen by the segmentation-reconcile effect below, so it can distinguish + * "the focused token's segment id changed because the segmentation changed" (commit immediately) + * from "focus moved" (the focus-change machinery owns the commit timing). + */ + const prevFocusForSegmentationRef = useRef(focusedTokenRef); + + // Reconcile the committed active segment when a segmentation edit (merge/split) changes the + // focused token's segment id without moving focus. Token refs survive re-segmentation, so no + // focus-change effect fires for such an edit; without this the committed id would keep naming a + // segment that no longer exists, deactivating every link button until the next navigation. Only + // fires while focus is unchanged — a focus move commits through its own paths (deferred to the + // scroll settle for internal nav, behind the fade for external jumps), which this must not + // preempt. A real commit also flips the active-segment recenter effect below, re-pinning the + // focused group against the edit's relayout. + useEffect(() => { + const focusUnchanged = prevFocusForSegmentationRef.current === focusedTokenRef; + prevFocusForSegmentationRef.current = focusedTokenRef; + if (!focusUnchanged) return; + commitPendingActiveSegment(); + }, [tokenSegmentMap, focusedTokenRef, commitPendingActiveSegment]); + /** Ref mirror of `onFocusedTokenRefChange` so callbacks never need it as a dep. */ const onFocusedTokenRefChangeRef = useLatestRef(onFocusedTokenRefChange); @@ -537,36 +581,39 @@ export default function ContinuousView({ // The snapped-slot paint has happened; re-enable the transition for later in-view toggles. setSkipSlotTransitionForJump(false); }); + // Hold the group centered through the reveal. The window slide mounts/unmounts groups whose + // arcs and morpheme rows finish laying out asynchronously over the next frames, shifting the + // strip after the instant snap above. The committed-active-segment layout effect re-centers + // against that drift, but only when the segment id actually flips — a jump landing in the + // already-active segment (a token click in the active verse) gets no correction there and was + // revealed off-center. Hold on the shared clock so every instant jump stays pinned regardless + // of whether the active segment changed. + const cancelHold = holdCentered(focusPhraseIndex); return () => { cancelAnimationFrame(rafId); + cancelHold(); setIsVisible(true); }; - }, [focusPhraseIndex, commitPendingActiveSegment, centerGroup]); + }, [focusPhraseIndex, commitPendingActiveSegment, centerGroup, holdCentered]); // Keep the focused group pinned dead-center after the deferred active-segment flip. When // `committedActiveSegmentId` flips (after an internal-nav scroll settles), inactive link icons // fade in/out over `LINK_SLOT_TRANSITION_MS`. Because they are hidden via `opacity: 0` their // layout space is preserved, so boxes do not shift — but any residual sub-pixel drift from the - // preceding smooth scroll is corrected by re-centering once before paint. The rAF loop holds the - // group centered for the full fade duration as a conservative guard against any future layout - // changes that could re-introduce drift. The first run is skipped because the initial center is - // established by the scroll effect's instant jump. A `useLayoutEffect` seeds the loop so the very - // first re-center lands before paint (no initial flash), then `rAF` carries it through the fade. + // preceding smooth scroll is corrected by re-centering once before paint. The shared hold loop + // keeps the group centered for the full fade duration as a conservative guard against any future + // layout changes that could re-introduce drift. The first run is skipped because the initial + // center is established by the scroll effect's instant jump. A `useLayoutEffect` seeds the loop + // with a synchronous re-center so the very first correction lands before paint (no initial + // flash), then the hold's `rAF` carries it through the fade. const skipActiveSegmentRecenterRef = useRef(true); useLayoutEffect(() => { if (skipActiveSegmentRecenterRef.current) { skipActiveSegmentRecenterRef.current = false; return undefined; } - /** Re-centers the focused group; called synchronously now and each `rAF` until the deadline. */ - const recenter = () => centerGroup(focusPhraseIndex, 'auto'); - recenter(); - const deadline = performance.now() + LINK_SLOT_TRANSITION_MS; - let rafId = requestAnimationFrame(function recenterFrame() { - recenter(); - if (performance.now() < deadline) rafId = requestAnimationFrame(recenterFrame); - }); - return () => cancelAnimationFrame(rafId); + centerGroup(focusPhraseIndex, 'auto'); + return holdCentered(focusPhraseIndex); // Only the active-segment flip should trigger this re-anchor; focusPhraseIndex has its own scroll // effect. Reading it here is a snapshot, not a trigger. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/Interlinearizer.tsx b/src/components/Interlinearizer.tsx index 5bf1c6b7..105e9233 100644 --- a/src/components/Interlinearizer.tsx +++ b/src/components/Interlinearizer.tsx @@ -2,7 +2,18 @@ import type { SerializedVerseRef } from '@sillsdev/scripture'; import type { Book, ScriptureRef, Segment, TextAnalysis } from 'interlinearizer'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; -import { AnalysisStoreProvider, usePhraseDispatch } from './AnalysisStore'; +import { + AnalysisStoreProvider, + usePhraseDispatch, + usePhraseLinkByIdGetter, + usePhraseLinkByIdMap, +} from './AnalysisStore'; +import { + NO_OP_SEGMENTATION_DISPATCH, + SegmentationProvider, + type SegmentationContextValue, + type SegmentationDispatch, +} from './SegmentationStore'; import ContinuousView from './ContinuousView'; import EditPhraseControls from './controls/EditPhraseControls'; import useBookIndexes from '../hooks/useBookIndexes'; @@ -10,7 +21,8 @@ import useLatestRef from '../hooks/useLatestRef'; import type { PhraseMode } from '../types/phrase-mode'; import type { ViewOptions } from '../types/view-options'; import { isWordToken } from '../types/type-guards'; -import { isSameVerse, toSerializedVerseRef } from '../utils/verse-ref'; +import { phrasesStraddlingBoundary, splitPhraseAtBoundary } from '../utils/phrase-arc'; +import { isSameVerse, segmentContainsVerse, toSerializedVerseRef } from '../utils/verse-ref'; import SegmentListView from './SegmentListView'; import UnlinkPhraseConfirm from './modals/UnlinkPhraseConfirm'; import { useInterlinearNav } from './InterlinearNavContext'; @@ -28,6 +40,9 @@ function firstWordTokenRefOf(segment: Segment | undefined): string | undefined { return segment?.tokens.find(isWordToken)?.ref; } +/** Stable empty map used as the `formerBoundaries` default so memoization isn't broken. */ +const EMPTY_FORMER_BOUNDARIES: ReadonlyMap = new Map(); + /** Props for {@link Interlinearizer}. */ type InterlinearizerProps = Readonly<{ /** Tokenized book whose segments are rendered. */ @@ -35,14 +50,15 @@ type InterlinearizerProps = Readonly<{ /** When true, the horizontal token strip is shown above the segment list. */ continuousScroll: boolean; /** - * Current scripture reference used to highlight the active verse. Always names a verse some - * segment in `book` starts at, provided the referenced chapter has segments: the loader keeps + * Current scripture reference used to highlight the active verse. Always names a verse contained + * in some segment of `book`, provided the referenced chapter has segments: the loader keeps * `verseNum: 0` when the active chapter has a verse-0 superscription segment (so the * superscription becomes the active verse), resolves a whole-chapter (verse 0) selection to the - * chapter's first numbered verse, and resolves any other unmatched verse — the host's next-verse - * button past the chapter's end, or a verse inside a multi-verse segment — to the nearest - * preceding segment start in the chapter. So this is normally verse >= 1, and is verse 0 only - * when a matching verse-0 segment exists. + * chapter's first numbered verse, and resolves a verse no segment contains — the host's + * next-verse button past the chapter's end — to the nearest preceding segment start in the + * chapter. A verse absorbed mid-segment by a merge passes through unchanged and resolves here by + * containment. So this is normally verse >= 1, and is verse 0 only when a matching verse-0 + * segment exists. */ scrRef: SerializedVerseRef; /** @@ -70,6 +86,25 @@ type InterlinearizerProps = Readonly<{ * affordances. Forwarded straight to {@link AnalysisStoreProvider}. Defaults to `false`. */ showSuggestions?: boolean; + /** + * Boundary-editing operations provided to the views via {@link SegmentationProvider}. Optional so + * isolated tests can omit it; the real loader always supplies it. Defaults to an inert no-op. + */ + segmentationDispatch?: SegmentationDispatch; + /** + * Maps each merged-away default verse boundary's word-token split anchor (the verse's first word + * token) to the removed default start ref, provided to the views via {@link SegmentationProvider} + * so slots on those anchors render the former-boundary tick and a split there restores the + * original boundary exactly. Optional so isolated tests can omit it; defaults to an empty map. + */ + formerBoundaries?: ReadonlyMap; + /** + * Monotonic counter the loader bumps on every boundary edit. Threaded to the segment window so it + * can tell a boundary edit (redraw in place) apart from a re-tokenization of the loaded book + * (recenter with a fade) when the segments identity changes. Optional so isolated tests can omit + * it; defaults to `0`. + */ + segmentationVersion?: number; }>; /** @@ -95,6 +130,9 @@ function InterlinearizerInner({ phraseMode, setPhraseMode, viewOptions, + segmentationDispatch = NO_OP_SEGMENTATION_DISPATCH, + formerBoundaries = EMPTY_FORMER_BOUNDARIES, + segmentationVersion = 0, }: Omit< InterlinearizerProps, 'initialAnalysis' | 'analysisLanguage' | 'onSaveAnalysis' | 'showSuggestions' @@ -105,18 +143,21 @@ function InterlinearizerInner({ const { navigate, consumeInternalNav, reportSettled } = useInterlinearNav(); /** - * Finds the book segment that owns the active verse named by `scrRef`, matching on book, chapter, - * and verse. Book must be matched too: during an external navigation the new `scrRef.book` is set - * before its book data finishes loading, so the still-mounted `book` belongs to the previous - * reference. A chapter+verse-only match would then resolve to the wrong book's verse (e.g. - * Genesis 15 while navigating to Matthew 15), seed `focusedTokenRef` from it, and the echo-back - * effect would fire that wrong-book verse back as `scrRef`, corrupting the global reference. - * Returning `undefined` until the matching book is mounted keeps focus unset rather than wrong. + * Finds the book segment that owns the active verse named by `scrRef`: the first segment in + * document order whose verse range contains it. Containment (rather than an exact start-verse + * match) matters after boundary edits — a verse absorbed into a multi-verse segment, or named by + * a later portion of a split verse, still resolves to the segment that holds its text. Book must + * be matched too: during an external navigation the new `scrRef.book` is set before its book data + * finishes loading, so the still-mounted `book` belongs to the previous reference. A + * chapter+verse-only match would then resolve to the wrong book's verse (e.g. Genesis 15 while + * navigating to Matthew 15), seed `focusedTokenRef` from it, and the echo-back effect would fire + * that wrong-book verse back as `scrRef`, corrupting the global reference. Returning `undefined` + * until the matching book is mounted keeps focus unset rather than wrong. * * @returns The active verse's segment, or `undefined` when no segment matches. */ const findActiveSegment = useCallback( - () => book.segments.find((seg) => isSameVerse(seg.startRef, scrRef)), + () => book.segments.find((seg) => segmentContainsVerse(seg, scrRef)), [book.segments, scrRef], ); @@ -127,16 +168,116 @@ function InterlinearizerInner({ firstWordTokenRefOf(findActiveSegment()), ); - // Reseed when the book changes — the previous focusedTokenRef refers to a token from another - // book and would never resolve in the new book's maps. + // Book-wide lookup indexes the views share, built in one pass over the segment list. + const { + segmentById, + segmentOrder, + tokenDocOrder, + fullTokenOrder, + tokenSegmentMap, + wordTokenByRef, + wordRefByOrder, + } = useBookIndexes(book); + + // Reseed when the new book no longer resolves the focused token — a real book change (the + // previous focusedTokenRef names a token from another book) or a re-tokenization that dropped the + // token. A boundary edit (merge/split) also produces a fresh `book` object, but token refs + // survive re-segmentation, so a still-resolving focus must be kept: reseeding would snap the + // continuous strip to the active verse's first word — or park it on the book's very first phrase + // when a merge removed the active verse's segment start (no segment startRef names that verse + // anymore, so findActiveSegment returns undefined). useEffect(() => { - setFocusedTokenRef(firstWordTokenRefOf(findActiveSegment())); - // findActiveSegment changes with scrRef too; only re-seed on book change. + setFocusedTokenRef((current) => + current !== undefined && wordTokenByRef.has(current) + ? current + : firstWordTokenRefOf(findActiveSegment()), + ); + // findActiveSegment changes with scrRef too, and wordTokenByRef derives from book; only re-seed + // on book change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [book]); - // Book-wide lookup indexes the views share, built in one pass over the segment list. - const { segmentById, tokenDocOrder, tokenSegmentMap, wordTokenByRef } = useBookIndexes(book); + const phraseDispatch = usePhraseDispatch(); + const getPhraseLinkById = usePhraseLinkByIdGetter(); + const phraseLinkById = usePhraseLinkByIdMap(); + + /** + * Word-token refs where placing a segment boundary would cut a phrase — the not-mid-phrase UI + * guard, precomputed once per phrase-link change. A boundary before ref `W` cuts a phrase when + * the phrase has tokens both strictly before and at-or-after `W`, i.e. every word ref strictly + * inside the phrase's document-order span `(min, max]` — including refs in the gaps of a + * discontiguous phrase. Computed here (one store subscription, one pass over the links) instead + * of per boundary slot, so hover-driven strip re-renders do O(1) set lookups rather than each + * slot rescanning every phrase link. + */ + const straddledBoundaryRefs = useMemo>(() => { + const blocked = new Set(); + phraseLinkById.forEach((link) => { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + link.tokens.forEach((t) => { + const order = tokenDocOrder.get(t.tokenRef); + /* v8 ignore next -- phrase tokens are word tokens, which the doc-order map always contains */ + if (order === undefined) return; + if (order < min) min = order; + if (order > max) max = order; + }); + for (let i = min + 1; i <= max; i += 1) blocked.add(wordRefByOrder[i]); + }); + return blocked; + }, [phraseLinkById, tokenDocOrder, wordRefByOrder]); + + /** + * Splits every phrase that a new segment boundary before `boundaryRef` would cut, so no phrase + * ever spans two segments. Reads the phrase links at call time (not via subscription) and applies + * {@link splitPhraseAtBoundary} at each straddled phrase's boundary-side split point. + * + * @param boundaryRef - Token ref the new segment will begin at. + */ + const forceBreakStraddledPhrases = useCallback( + (boundaryRef: string) => { + phrasesStraddlingBoundary(boundaryRef, getPhraseLinkById().values(), fullTokenOrder).forEach( + ({ link, splitAfterTokenRef }) => + splitPhraseAtBoundary(link, splitAfterTokenRef, phraseDispatch, tokenDocOrder), + ); + }, + [getPhraseLinkById, fullTokenOrder, phraseDispatch, tokenDocOrder], + ); + + /** + * The dispatch the views receive: wraps the raw boundary writer so any operation that adds a + * boundary (split, and the add-half of move) first force-breaks the phrases the new boundary + * would cut. Merge only removes a boundary, which can never leave a phrase straddling segments, + * so it passes through. The token-chip views suppress their controls at straddling boundaries via + * the same predicate, so the force-break normally fires only for callers that cannot see + * phrases. + */ + const dispatch = useMemo( + () => ({ + merge: segmentationDispatch.merge, + split: (tokenRef) => { + forceBreakStraddledPhrases(tokenRef); + segmentationDispatch.split(tokenRef); + }, + move: (fromRef, toRef) => { + forceBreakStraddledPhrases(toRef); + segmentationDispatch.move(fromRef, toRef); + }, + }), + [segmentationDispatch, forceBreakStraddledPhrases], + ); + + /** Segmentation context shared by the views — the dispatch plus the lookups its call sites need. */ + const segmentationValue = useMemo( + () => ({ + dispatch, + segmentById, + segmentOrder, + formerBoundaries, + straddledBoundaryRefs, + }), + [dispatch, segmentById, segmentOrder, formerBoundaries, straddledBoundaryRefs], + ); /** PhraseId currently hovered anywhere in the interlinearizer; shared across all SegmentViews. */ const [hoveredPhraseId, setHoveredPhraseId] = useState(); @@ -238,7 +379,10 @@ function InterlinearizerInner({ if (!seg) return; const { current } = scrRefRef; if (seg.startRef.book !== current.book) return; - if (isSameVerse(seg.startRef, current)) return; + // Already-contained check (not exact start-verse match): when the active verse sits inside a + // multi-verse segment, focusing another of that segment's tokens must not renavigate to the + // segment's start verse — the reference is already within the focused segment. + if (segmentContainsVerse(seg, current)) return; navigate(toSerializedVerseRef(seg.startRef), 'internal'); }, [segmentById, tokenSegmentMap, navigate, scrRefRef], @@ -266,59 +410,62 @@ function InterlinearizerInner({ ); return ( -
- {(phraseMode.kind === 'confirm-unlink' || phraseMode.kind === 'edit') && ( -
- {phraseMode.kind === 'confirm-unlink' ? ( - - ) : ( - - )} -
- )} -
- {displayContinuousScroll && ( -
- + +
+ {(phraseMode.kind === 'confirm-unlink' || phraseMode.kind === 'edit') && ( +
+ {phraseMode.kind === 'confirm-unlink' ? ( + + ) : ( + + )}
)} +
+ {displayContinuousScroll && ( +
+ +
+ )} - + +
-
+ ); } diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index a1b78792..6dda5b9c 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -10,9 +10,18 @@ import type { SelectMenuItemHandler } from 'platform-bible-react'; import type { ScriptureRef } from 'interlinearizer'; import { isPlatformError } from 'platform-bible-utils'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { resegmentBook } from 'parsers/papi/resegmentBook'; import useDraftProject from '../hooks/useDraftProject'; import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData'; import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting'; +import { + isDefaultSegmentation, + mergeSegments, + moveBoundary, + splitSegmentBefore, +} from '../utils/segmentation'; +import { isWordToken } from '../types/type-guards'; +import type { SegmentationDispatch } from './SegmentationStore'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; import Interlinearizer from './Interlinearizer'; import ViewOptionsDropdown from './controls/ViewOptionsDropdown'; @@ -22,7 +31,7 @@ import { WipeModal, type WipeScope } from './modals/WipeModal'; import ScriptureNavControls from './controls/ScriptureNavControls'; import { InterlinearNavProvider, useInterlinearNav } from './InterlinearNavContext'; import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; -import { isSameVerse, toSerializedVerseRef } from '../utils/verse-ref'; +import { segmentContainsVerse, toSerializedVerseRef } from '../utils/verse-ref'; /** Host-injected callback to update this WebView's definition (used to toggle the tab title). */ type UpdateWebViewDefinition = WebViewProps['updateWebViewDefinition']; @@ -135,8 +144,10 @@ function InterlinearizerLoaderInner({ isDraftLoading, draft, draftVersion, + segmentationVersion, dirty, autosaveAnalysis, + autosaveSegmentation, loadFromProject, newDraft, getDraftSnapshot, @@ -232,29 +243,105 @@ function InterlinearizerLoaderInner({ ], ); - const { book, isLoading, bookError, tokenizeError } = useInterlinearizerBookData({ + const { + book: verseBook, + isLoading, + bookError, + tokenizeError, + } = useInterlinearizerBookData({ projectId, scrRef, }); - // The active reference handed to the interlinearizer, resolved so it always names a verse some - // segment starts at (when the requested chapter has segments at all). A reference whose verse is - // a segment start passes through unchanged. Otherwise: - // - // - Verse 0 with no verse-0 segment is a plain whole-chapter selection (the host emits - // `verseNum: 0` for those as well as for superscriptions), so it falls back to the chapter's - // first numbered verse rather than leaving nothing highlighted. - // - Any other unmatched verse resolves to the nearest preceding segment start in the same - // chapter. This covers the host's next-verse button, which emits `verseNum + 1` without - // clamping — bumping forward from a chapter's last verse delivers a verse past the chapter's - // end, which must land on the chapter's last segment, not fall through to the views' anchor - // fallback (the start of the book). It also covers verses inside a multi-verse segment, which - // resolve to the segment that contains them. - // - A reference to a chapter with no segments (including any book mismatch during a cross-book - // swap) passes through unchanged; the views' own fallbacks handle that transient. + /** + * The book the views render: the verse-tokenized book re-grouped into the user's custom segments. + * Identical (by reference) to `verseBook` when no custom boundaries are set, so the common case + * incurs no extra work. `verseBook` is retained separately because the segmentation operations + * need the default verse boundaries it carries. + * + * `draft.segmentation` is read fresh from the ref-held draft at recompute time; the deps are the + * two version counters that cover every path that can change it — `segmentationVersion` for + * boundary edits and `draftVersion` for wholesale replacements (New / Open / Wipe). The draft + * object itself is deliberately NOT a dep: gloss auto-saves replace the draft identity on every + * write without touching the boundaries, and keying on it would re-run the full-book + * re-segmentation (and cascade a fresh `book` identity through every downstream index and memo) + * after every gloss edit. `isDraftLoading` covers the one replacement that bumps neither counter: + * the initial draft load, which must invalidate a book computed while the stored boundaries were + * still in flight. + */ + const book = useMemo( + () => (verseBook ? resegmentBook(verseBook, draft?.segmentation) : undefined), + // eslint-disable-next-line react-hooks/exhaustive-deps -- the version counters track draft?.segmentation, a ref value + [verseBook, segmentationVersion, draftVersion, isDraftLoading], + ); + + /** + * Maps each merged-away default verse boundary's word-token split anchor — the verse's first word + * token, the ref the boundary slots are keyed by — to the removed default start ref (the verse's + * first token of any type, per the delta's `removedVerseStarts`). The two differ when a verse + * begins with punctuation (e.g. an opening quote); mapping through the word anchor lets the slot + * render the former-boundary tick and dispatch a split that cancels the removal exactly. Deps + * mirror the `book` memo above: the version counters cover every path that changes the + * segmentation, `isDraftLoading` covers the initial draft load, and the draft identity is + * deliberately not a dep. + */ + const formerBoundaries = useMemo>(() => { + const map = new Map(); + const removed = draft?.segmentation?.removedVerseStarts ?? []; + if (removed.length === 0 || !verseBook) return map; + const removedSet = new Set(removed); + verseBook.segments.forEach((verse) => { + const startRef = verse.tokens[0]?.ref; + if (startRef === undefined || !removedSet.has(startRef)) return; + const wordAnchorRef = verse.tokens.find(isWordToken)?.ref; + if (wordAnchorRef !== undefined) map.set(wordAnchorRef, startRef); + }); + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps -- the version counters track draft?.segmentation, a ref value + }, [verseBook, segmentationVersion, draftVersion, isDraftLoading]); + + /** + * Boundary-editing operations passed down to the views. Each reads the draft's latest boundary + * delta synchronously (so rapid edits compose correctly), applies the relevant pure transform + * against the original verse book, and auto-saves the normalized result — clearing the field back + * to `undefined` when the edit restores the default verse segmentation. + */ + const segmentationDispatch = useMemo(() => { + const apply = (next: ReturnType) => { + autosaveSegmentation(isDefaultSegmentation(next) ? undefined : next); + }; + return { + merge: (secondSegmentStartRef) => { + /* v8 ignore next -- boundary controls only render once the book has loaded */ + if (!verseBook) return; + apply(mergeSegments(verseBook, getDraftSnapshot()?.segmentation, secondSegmentStartRef)); + }, + split: (tokenRef) => { + /* v8 ignore next -- boundary controls only render once the book has loaded */ + if (!verseBook) return; + apply(splitSegmentBefore(verseBook, getDraftSnapshot()?.segmentation, tokenRef)); + }, + move: (fromRef, toRef) => { + /* v8 ignore next -- the cross-segment link only renders once the book has loaded */ + if (!verseBook) return; + apply(moveBoundary(verseBook, getDraftSnapshot()?.segmentation, fromRef, toRef)); + }, + }; + }, [verseBook, getDraftSnapshot, autosaveSegmentation]); + + // The active reference handed to the interlinearizer. The host emits `verseNum: 0` both for a + // chapter's verse-0 superscription (which has its own segment) and for a plain whole-chapter + // selection (which does not). Keep verse 0 when the loaded book actually has a verse-0 segment for + // that chapter — so a Psalm superscription becomes the active verse — and otherwise fall back to + // the chapter's first numbered verse, so an ordinary chapter selection still lands on verse 1 + // rather than leaving nothing highlighted. A reference contained in any segment's verse range — + // including a verse absorbed mid-segment by a merge — passes through unchanged; the views resolve + // it to its containing segment. Only a reference no segment contains at all (the host's + // next-verse over-shooting the chapter's end) resolves to the nearest preceding segment start in + // the same chapter (falling through unchanged when the chapter has none). const activeScrRef = useMemo(() => { if (!book) return scrRef; - if (book.segments.some((segment) => isSameVerse(segment.startRef, scrRef))) return scrRef; + if (book.segments.some((segment) => segmentContainsVerse(segment, scrRef))) return scrRef; if (scrRef.verseNum === 0) return { ...scrRef, verseNum: 1 }; let preceding: ScriptureRef | undefined; book.segments.forEach(({ startRef }) => { @@ -330,8 +417,12 @@ function InterlinearizerLoaderInner({ 'interlinearizer.saveAnalysis', activeProject.id, JSON.stringify(snapshot.analysis), + // Send the draft's boundary state on every Save; `null` clears any stored boundaries so a + // reverted segmentation propagates to the project rather than leaving it stale. + // eslint-disable-next-line no-null/no-null -- "null" is the JSON sentinel that clears boundaries + JSON.stringify(snapshot.segmentation ?? null), ); - markSynced(snapshot.analysis); + markSynced(snapshot.analysis, snapshot.segmentation); } catch (e) { logger.error('Interlinearizer: failed to save draft to project', e); } finally { @@ -514,6 +605,9 @@ function InterlinearizerLoaderInner({ setPhraseMode={setPhraseMode} viewOptions={viewOptions} showSuggestions={showSuggestions} + segmentationDispatch={segmentationDispatch} + formerBoundaries={formerBoundaries} + segmentationVersion={segmentationVersion} /> )}
@@ -521,7 +615,7 @@ function InterlinearizerLoaderInner({ { if (isBoxSplitFree) return 'tw:phrase-destructive'; + // Candidate previews outrank focus/hover: while a preview is hovered, showing what the + // operation affects matters more than showing what is focused. + if (isCandidate) return 'tw:phrase-candidate'; if (isFocused) return 'tw:phrase-focused'; if (isHighlighted) return 'tw:phrase-hovered'; return 'tw:phrase-dimmed'; diff --git a/src/components/PhraseStripContext.tsx b/src/components/PhraseStripContext.tsx index e0a7fb05..7e2d4fc6 100644 --- a/src/components/PhraseStripContext.tsx +++ b/src/components/PhraseStripContext.tsx @@ -40,7 +40,10 @@ export type PhraseStripContextValue = Readonly<{ * function for them. */ onHoverPhrase: (phraseId: string | undefined) => void; - /** Called with the candidate token refs (or `undefined`) when a link icon is hovered. */ + /** + * Called with the candidate token refs (or `undefined`) when a link icon or a boundary + * merge/split control is hovered; the affected groups render the strong candidate preview style. + */ onHoverCandidateTokens: (refs: readonly string[] | undefined) => void; /** Called with the would-be-free token refs (or `undefined`) when a split/unlink icon is hovered. */ onHoverSplitFreeTokens: (refs: readonly string[] | undefined) => void; diff --git a/src/components/PhraseStripParts.tsx b/src/components/PhraseStripParts.tsx index e1efc91a..2c91de70 100644 --- a/src/components/PhraseStripParts.tsx +++ b/src/components/PhraseStripParts.tsx @@ -1,13 +1,238 @@ /** @file Shared render parts for the two phrase strips (SegmentView and ContinuousView). */ +import { useLocalizedStrings } from '@papi/frontend/react'; +import { FoldHorizontal, Scissors } from 'lucide-react'; import { memo } from 'react'; +import type { ReactNode } from 'react'; import MemoizedPhraseBox from './PhraseBox'; import type { PhraseMode } from '../types/phrase-mode'; import { usePhraseStripContext } from './PhraseStripContext'; +import { useSegmentation } from './SegmentationStore'; import { InertTokenChip } from './TokenChip'; import MemoizedTokenLinkIcon from './TokenLinkIcon'; import type { FocusContext, LinkSlot, TokenGroup } from '../types/token-layout'; +import { isWordToken } from '../types/type-guards'; import { resolveSlotFocus } from '../utils/token-layout'; +/** Localized labels for the merge/split boundary controls; hoisted so the array reference is stable. */ +const BOUNDARY_STRING_KEYS = [ + '%interlinearizer_boundaryControl_merge%', + '%interlinearizer_boundaryControl_split%', + '%interlinearizer_boundaryControl_formerBoundary%', +] as const satisfies `%${string}%`[]; + +/** Props for {@link BoundaryButton}. */ +type BoundaryButtonProps = Readonly<{ + /** Accessible label, doubling as the tooltip. */ + label: string; + /** `data-testid` for the button element. */ + testId: string; + /** The icon rendered inside the button. */ + icon: ReactNode; + /** When `true` the control renders inert; used while a phrase mode (edit / unlink) is active. */ + disabled: boolean; + /** + * Lazily computes the word-token refs the operation would affect. Called on hover (not render), + * so mounting hundreds of slots does no per-render array building. + * + * @returns The refs to highlight as the operation's preview. + */ + getPreviewRefs: () => readonly string[]; + /** The boundary edit to run on click. */ + action: () => void; +}>; + +/** + * One boundary-edit button (merge or split): shared markup, hover-preview wiring, and click + * cleanup. Entering highlights the refs from `getPreviewRefs` through the shared candidate-token + * channel (the same one the link icon uses), leaving clears, and click clears synchronously before + * running the edit so the highlight can't linger over the re-segmented content. + * + * @param props - Component props. + * @param props.label - Accessible label and tooltip. + * @param props.testId - `data-testid` for the button element. + * @param props.icon - The icon rendered inside the button. + * @param props.disabled - Renders the control inert while a phrase mode is active. + * @param props.getPreviewRefs - Lazily computes the refs the operation would affect. + * @param props.action - The boundary edit to run on click. + * @returns The styled boundary button. + */ +function BoundaryButton({ + label, + testId, + icon, + disabled, + getPreviewRefs, + action, +}: BoundaryButtonProps) { + const { onHoverCandidateTokens } = usePhraseStripContext(); + return ( + + ); +} + +/** Props for {@link BoundaryControl}. */ +type BoundaryControlProps = Readonly<{ + /** Segment id of the group before the slot, or `undefined` for the leading slot. */ + prevSegmentId: string | undefined; + /** Segment id of the group after the slot, or `undefined` for the trailing slot. */ + nextSegmentId: string | undefined; + /** + * Last word token before the slot, or `undefined` for a leading slot. A slot with no word token + * before it sits on an existing segment start, where a split would be a no-op, so no control + * renders there. + */ + prevTokenRef: string | undefined; + /** First word token after the slot, used as the split anchor. */ + nextTokenRef: string | undefined; +}>; + +/** + * Renders the boundary-edit control for one slot, always visible (not hover-gated). A slot + * straddling two different segments shows a merge control (combine the next segment into the + * previous one); a slot inside one segment shows a split control (start a new segment at the next + * token). Leading/trailing slots (a word token missing on either side) render nothing — a leading + * slot sits on an existing segment start, where a split would be a no-op. + * + * Hovering either control previews its effect through the shared candidate-token highlight (the + * same channel the link icon uses): merge highlights every word token of both segments (they become + * one segment); split highlights the word tokens from the split anchor to the segment end (the run + * that breaks off into the new segment). The preview is cleared on leave and synchronously on click + * so it can't linger over the re-segmented content. + * + * An intra-segment slot sitting on a merged-away default verse start additionally renders a faint + * dashed tick — the former verse boundary — so the user can see where a split would restore the + * original segmentation. The tick shows even when the split control itself is suppressed by the + * not-mid-phrase guard, since it is informational rather than interactive. + * + * The not-mid-phrase rule is a UI guard applied here: no split control renders at a boundary that + * would cut a phrase — including the gap between two fragments of a discontiguous phrase. (The + * segmentation dispatch itself accepts such boundaries and force-breaks the straddled phrases; only + * callers that cannot see token chunks take that path.) Merge needs no such guard: removing a + * boundary can never leave a phrase straddling one. + * + * Both controls render disabled while a phrase mode (edit / confirm-unlink) is active, matching the + * link icons: a boundary edit mid-mode could re-segment the phrase the mode UI is operating on + * (e.g. canceling an edit would then restore a phrase spanning the new boundary). + * + * @param props - Component props. + * @param props.prevSegmentId - Segment id before the slot. + * @param props.nextSegmentId - Segment id after the slot. + * @param props.prevTokenRef - Last word token before the slot. + * @param props.nextTokenRef - First word token after the slot (split anchor). + * @returns A merge or split button (with an optional former-boundary tick), or `undefined` when the + * slot is at a segment or book edge or nothing applies at this boundary. + */ +function BoundaryControl({ + prevSegmentId, + nextSegmentId, + prevTokenRef, + nextTokenRef, +}: BoundaryControlProps) { + const { dispatch, segmentById, formerBoundaries, straddledBoundaryRefs } = useSegmentation(); + const { phraseMode } = usePhraseStripContext(); + const [localizedStrings] = useLocalizedStrings(BOUNDARY_STRING_KEYS); + if ( + prevSegmentId === undefined || + nextSegmentId === undefined || + prevTokenRef === undefined || + nextTokenRef === undefined + ) { + return undefined; + } + + const boundaryEditsDisabled = phraseMode.kind !== 'view'; + + const control = (() => { + if (prevSegmentId !== nextSegmentId) { + const nextSegment = segmentById.get(nextSegmentId); + const secondStart = nextSegment?.tokens[0]?.ref; + /* v8 ignore next -- a rendered cross-segment slot always resolves the next segment's start */ + if (nextSegment === undefined || secondStart === undefined) return undefined; + return ( + } + disabled={boundaryEditsDisabled} + // Merging joins every token of both segments into one; preview exactly that. + getPreviewRefs={() => + [ + /* v8 ignore next -- a rendered cross-segment slot's previous segment is always mapped */ + ...(segmentById.get(prevSegmentId)?.tokens ?? []), + ...nextSegment.tokens, + ] + .filter(isWordToken) + .map((t) => t.ref) + } + action={() => dispatch.merge(secondStart)} + /> + ); + } + // The not-mid-phrase UI guard: no split control at a boundary that would cut a phrase. + if (straddledBoundaryRefs.has(nextTokenRef)) return undefined; + // A split on a former boundary dispatches the original removed default start — which may be a + // leading punctuation token no word-anchored slot could name — so the restore cancels the + // removal exactly and the delta can normalize back to the default segmentation. + const splitRef = formerBoundaries.get(nextTokenRef) ?? nextTokenRef; + return ( + } + disabled={boundaryEditsDisabled} + // Splitting breaks the run from the anchor to the segment end off into a new segment; + // preview exactly that run. + getPreviewRefs={() => { + const segmentTokens = segmentById.get(nextSegmentId)?.tokens ?? []; + const anchorIndex = segmentTokens.findIndex((t) => t.ref === nextTokenRef); + /* v8 ignore next -- an intra-segment slot's anchor is always a token of its segment */ + if (anchorIndex === -1) return []; + return segmentTokens + .slice(anchorIndex) + .filter(isWordToken) + .map((t) => t.ref); + }} + action={() => dispatch.split(splitRef)} + /> + ); + })(); + + // The former-boundary tick: an intra-segment slot sitting on a merged-away default verse start. + const formerBoundaryMarker = + prevSegmentId === nextSegmentId && formerBoundaries.has(nextTokenRef) ? ( +