Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"%interlinearizer_boundaryControl_merge%": "Join these two segments",
"%interlinearizer_boundaryControl_mergeAltHint%": "Join these two segments. Hold {key} and click between words to split.",
"%interlinearizer_boundaryControl_split%": "Split segment here",
"%interlinearizer_segmentation_lostBoundaries%": "The source text changed, so {count} of your segment boundaries no longer fit it and aren't shown. They'll come back on their own if the text is restored.",
"%interlinearizer_phraseBox_glossLabel%": "Phrase gloss",
"%interlinearizer_phraseBox_edit%": "Edit phrase",
"%interlinearizer_phraseBox_unlink%": "Unlink phrase",
Expand Down
129 changes: 129 additions & 0 deletions src/__tests__/components/InterlinearizerLoader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2534,6 +2534,135 @@ describe('InterlinearizerLoader', () => {
});
});

describe('lost segment boundaries', () => {
/** A two-verse book the deltas below anchor into. */
const TWO_VERSE_BOOK: Book = {
id: 'GEN',
bookRef: 'GEN',
textVersion: 'v1',
segments: [
makeSegment('GEN 1:1', 'Alpha beta.', [
makeWordToken('GEN 1:1:0', 'Alpha'),
makeWordToken('GEN 1:1:6', 'beta', 6),
]),
makeSegment('GEN 1:2', 'Gamma.', [makeWordToken('GEN 1:2:0', 'Gamma')]),
],
};

/**
* Renders the loader on {@link TWO_VERSE_BOOK} with the given persisted boundary delta,
* returning a `rerenderNow` that re-invokes the _same_ loader instance — the book-data mock
* mutates hook output rather than React state, so a rerender is what picks up a changed book.
*/
async function renderWithSegmentation(
segmentation: DraftProject['segmentation'],
): Promise<{ rerenderNow: () => void }> {
mockBookData({ book: TWO_VERSE_BOOK });
mockSendCommand.mockResolvedValue(
JSON.stringify({ ...emptyDraft(testProjectId), segmentation }),
);
const scrollGroupHook = makeScrollGroupHook();
const webViewState = makeWebViewState();
const buildUi = () => (
<InterlinearizerLoader
projectId={testProjectId}
useWebViewScrollGroupScrRef={scrollGroupHook}
useWebViewState={webViewState}
updateWebViewDefinition={jest.fn(() => true)}
/>
);
let view: ReturnType<typeof render> | undefined;
await act(async () => {
view = render(buildUi());
});
return { rerenderNow: () => view?.rerender(buildUi()) };
}

it('warns with the lost-anchor count when the source no longer has the anchored tokens', async () => {
jest.mocked(papi.notifications.send).mockResolvedValue('notification-id');
await renderWithSegmentation({
removedVerseStarts: ['GEN 1:9:0'],
addedStarts: ['GEN 1:1:99'],
});

expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith({
message: '%interlinearizer_segmentation_lostBoundaries%',
severity: 'warning',
});
});

it('does not warn when every anchor still resolves', async () => {
await renderWithSegmentation({
removedVerseStarts: ['GEN 1:2:0'],
addedStarts: ['GEN 1:1:6'],
});

expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled();
});

it('does not warn for the default segmentation', async () => {
await renderWithSegmentation(undefined);

expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled();
});

it('warns once per tokenization, not once per re-render of the same book', async () => {
jest.mocked(papi.notifications.send).mockResolvedValue('notification-id');
const view = await renderWithSegmentation({
removedVerseStarts: ['GEN 1:9:0'],
addedStarts: [],
});
expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledTimes(1);

// A duplicate GetText re-fetch hands the loader back the same `verseBook` reference, so the
// re-render it causes must not re-warn about a loss already reported.
await act(async () => {
view.rerenderNow();
});

expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledTimes(1);
});

it('warns again on a new tokenization that still loses anchors', async () => {
jest.mocked(papi.notifications.send).mockResolvedValue('notification-id');
const view = await renderWithSegmentation({
removedVerseStarts: ['GEN 1:9:0'],
addedStarts: [],
});
expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledTimes(1);

// A genuinely new tokenization — the source changed again — is a fresh loss of the same
// boundaries, and worth saying a second time.
mockBookData({ book: { ...TWO_VERSE_BOOK } });
await act(async () => {
view.rerenderNow();
});

expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledTimes(2);
});

it('leaves the dead anchors in the draft so they revive if the source comes back', async () => {
jest.mocked(papi.notifications.send).mockResolvedValue('notification-id');
const segmentation = { removedVerseStarts: ['GEN 1:9:0'], addedStarts: ['GEN 1:1:99'] };
await renderWithSegmentation(segmentation);

// The warning path is read-only: nothing persists a pruned delta in response to it.
const saves = mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraft');
expect(saves).toHaveLength(0);
});

it('logs a warning when the notification cannot be delivered', async () => {
jest.mocked(papi.notifications.send).mockRejectedValue(new Error('ui offline'));
await renderWithSegmentation({ removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] });

await waitFor(() => {
expect(jest.mocked(logger.warn)).toHaveBeenCalledWith(
expect.stringContaining('failed to warn about lost segment boundaries'),
);
});
});
});

describe('save command', () => {
it('saves the draft analysis to the active project when Save is clicked with an active project', async () => {
const draftAnalysis = emptyAnalysis();
Expand Down
30 changes: 30 additions & 0 deletions src/__tests__/utils/segmentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
defaultVerseStarts,
effectiveStarts,
isDefaultSegmentation,
lostAnchors,
mergeSegments,
moveBoundary,
removeBoundaryAt,
Expand Down Expand Up @@ -75,6 +76,35 @@ describe('isDefaultSegmentation', () => {
});
});

describe('lostAnchors', () => {
it('is empty for undefined', () => {
expect(lostAnchors(THREE_VERSES, undefined)).toEqual([]);
});

it('is empty when every anchor still names a token', () => {
const delta: SegmentationDelta = { removedVerseStarts: [V2_START], addedStarts: [V1_BETA] };
expect(lostAnchors(THREE_VERSES, delta)).toEqual([]);
});

it('reports a removed verse start whose token is gone', () => {
const delta: SegmentationDelta = { removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] };
expect(lostAnchors(THREE_VERSES, delta)).toEqual(['GEN 1:9:0']);
});

it('reports an added start whose char offset no longer exists', () => {
const delta: SegmentationDelta = { removedVerseStarts: [], addedStarts: ['GEN 1:1:99'] };
expect(lostAnchors(THREE_VERSES, delta)).toEqual(['GEN 1:1:99']);
});

it('reports losses from both arrays, keeping the surviving anchors out', () => {
const delta: SegmentationDelta = {
removedVerseStarts: [V2_START, 'GEN 1:9:0'],
addedStarts: [V1_BETA, 'GEN 1:1:99'],
};
expect(lostAnchors(THREE_VERSES, delta)).toEqual(['GEN 1:9:0', 'GEN 1:1:99']);
});
});

describe('effectiveStarts', () => {
it('returns all default verse starts for the default segmentation', () => {
expect(effectiveStarts(THREE_VERSES, undefined)).toEqual(
Expand Down
45 changes: 44 additions & 1 deletion src/components/InterlinearizerLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ import type { SelectMenuItemHandler } from 'platform-bible-react';
import { formatReplacementString, isPlatformError } from 'platform-bible-utils';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ComponentProps, ReactNode, RefObject } from 'react';
import type { TextAnalysis } from 'interlinearizer';
import type { Book, TextAnalysis } from 'interlinearizer';
import { resegmentBook } from 'parsers/papi/resegmentBook';
import useDraftProject from '../hooks/useDraftProject';
import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData';
import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting';
import {
isDefaultSegmentation,
lostAnchors,
mergeSegments,
moveBoundary,
splitSegmentBefore,
Expand Down Expand Up @@ -161,6 +162,7 @@ const STRING_KEYS = [
'%interlinearizer_banner_pt9Import%',
'%interlinearizer_banner_sync%',
'%interlinearizer_banner_copy%',
'%interlinearizer_segmentation_lostBoundaries%',
] as const satisfies `%${string}%`[];

/**
Expand Down Expand Up @@ -478,6 +480,47 @@ function InterlinearizerLoaderInner({
[verseBook, segmentationVersion, draftVersion, isDraftLoading, isImportView],
);

/**
* The last book the lost-boundary warning was evaluated against, so a re-fetch that tokenizes to
* the same book does not re-warn about a loss already reported.
*/
const lastLostAnchorsBookRef = useRef<Book | undefined>(undefined);

/**
* Warns when the source text has changed out from under the draft's segment boundaries, whose
* anchors are then dropped and the region reverted to one segment per verse — silently, without
* this.
*
* Every new tokenization that still loses anchors warns again: a reversify, revert, reversify
* cycle loses the same boundaries three separate times.
*/
useEffect(() => {
if (!verseBook || isImportView || isDraftLoading) return;
if (lastLostAnchorsBookRef.current === verseBook) return;
lastLostAnchorsBookRef.current = verseBook;
const lost = lostAnchors(verseBook, draft?.segmentation);
if (lost.length === 0) return;
papi.notifications
.send({
message: formatReplacementString(
localizedStrings['%interlinearizer_segmentation_lostBoundaries%'],
{ count: lost.length },
),
severity: 'warning',
})
.catch((error: unknown) => {
logger.warn(`Interlinearizer: failed to warn about lost segment boundaries: ${error}`);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- the version counters track draft?.segmentation, a ref value
}, [
verseBook,
segmentationVersion,
draftVersion,
isDraftLoading,
isImportView,
localizedStrings,
]);

/**
* 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
Expand Down
16 changes: 16 additions & 0 deletions src/utils/segmentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,19 @@ export function splitSegmentBefore(
export function isDefaultSegmentation(delta: SegmentationDelta | undefined): boolean {
return !delta || (delta.removedVerseStarts.length === 0 && delta.addedStarts.length === 0);
}

/**
* The delta's anchors that no longer name a token in the book, in delta order — the boundaries
* {@link effectiveStarts} silently drops, which a reversified or upstream-edited source produces
* because both re-key the token refs anchors are written against.
*
* The delta itself is left intact, so a source that reverts brings its boundaries back.
*/
export function lostAnchors(
verseBook: Book,
delta: SegmentationDelta | undefined,
): readonly string[] {
if (!delta) return [];
const { all } = bookLookups(verseBook);
return [...delta.removedVerseStarts, ...delta.addedStarts].filter((ref) => !all.has(ref));
}
Loading