Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c48cc94
Warn when the source text drops persisted segment boundaries
alex-rawlings-yyc Sep 2, 2026
5e26ba1
Scope segment-boundary anchors to the loaded book
alex-rawlings-yyc Sep 3, 2026
7ffe2c1
Hold the lost-boundary warning until its text is localized
alex-rawlings-yyc Sep 3, 2026
2835f6e
Show lost segment boundaries in a banner, not a toast
alex-rawlings-yyc Sep 4, 2026
afa6a0d
Hold every localized banner and heading until its text resolves
alex-rawlings-yyc Sep 4, 2026
723104c
Hold the lost-boundary banner until the book is loaded
alex-rawlings-yyc Sep 4, 2026
e1da8fb
Add duplicateVerseIds to the lost-boundaries test book
alex-rawlings-yyc Sep 8, 2026
4af154f
Keep unresolvable boundary anchors through later edits
alex-rawlings-yyc Sep 8, 2026
c3c1942
Reconcile a drifted anchor's deletion with its loss report
alex-rawlings-yyc Sep 8, 2026
43497de
Let a boundary edit override a drifted anchor at its ref
alex-rawlings-yyc Sep 8, 2026
2675aab
Reword the segmentation docs the review flagged
alex-rawlings-yyc Sep 9, 2026
2b3b2e0
Report a merge anchor left with nothing to merge into as lost
alex-rawlings-yyc Sep 9, 2026
f7a7345
Let the lost-boundary banner be dismissed
alex-rawlings-yyc Sep 9, 2026
6f25e6a
Drop a lost-boundary dismissal when the draft is replaced
alex-rawlings-yyc Sep 9, 2026
438361c
Raise the lost-boundary banner again when a recovered anchor is stranded
alex-rawlings-yyc Sep 9, 2026
c3cddb0
Keep a lost-boundary dismissal across a visit to another book
alex-rawlings-yyc Sep 9, 2026
d2d3b3a
Extract the lost-boundary dismissal tracking into a hook
alex-rawlings-yyc Sep 9, 2026
96d8d4b
Drop a dismissal a replacement's partial recovery restored
alex-rawlings-yyc Sep 9, 2026
23c6421
Report only boundaries the source has actually lost
alex-rawlings-yyc Sep 10, 2026
b403c2c
Name the lost-boundary report for what it returns
alex-rawlings-yyc Sep 10, 2026
32b7a21
Drop the lost-boundary banner's guard commentary
alex-rawlings-yyc Sep 10, 2026
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
3 changes: 3 additions & 0 deletions contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@
"%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 boundary changes no longer fit it and aren't applied.",
"%interlinearizer_segmentation_lostBoundaries_one%": "The source text changed, so one of your segment boundary changes no longer fits it and isn't applied.",
"%interlinearizer_segmentation_lostBoundaries_dismiss%": "Dismiss this warning",
"%interlinearizer_phraseBox_glossLabel%": "Phrase gloss",
"%interlinearizer_phraseBox_edit%": "Edit phrase",
"%interlinearizer_phraseBox_unlink%": "Unlink phrase",
Expand Down
164 changes: 163 additions & 1 deletion src/__tests__/components/InterlinearizerLoader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { useGlossDispatch } from '../../components/AnalysisStore';
import InterlinearizerLoader from '../../components/InterlinearizerLoader';
import { RECENTER_FADE_MS } from '../../components/recenter-fade';
import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData';
import useLostBoundaryDismissal from '../../hooks/useLostBoundaryDismissal';
import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting';
import type { OpenableProject } from '../../hooks/useDraftProject';
import { emptyAnalysis, emptyDraft } from '../../types/empty-factories';
import { PT9_MANIFEST_TIMEOUT_MS } from '../../utils/pt9-manifest';
import type { PhraseMode } from '../../types/phrase-mode';
Expand All @@ -33,6 +35,7 @@ import {
import { mockKeyAsValueLocalizedStrings } from './test-helpers';

jest.mock('../../hooks/useInterlinearizerBookData');
jest.mock('../../hooks/useLostBoundaryDismissal');
jest.mock('../../hooks/useOptimisticBooleanSetting');

jest.mock('../../components/controls/ViewOptionsDropdown', () => ({
Expand Down Expand Up @@ -277,6 +280,15 @@ const STUB_IMPORT_PROJECT: MockProject = {
pt9Import: { fileHashes: { 'Lexicon.xml': 'aaaa1111' }, importedAt: '2026-08-01T00:00:00Z' },
};

/**
* The project the stub picker's "Open project" button loads into the draft. Mutable so a test can
* choose the boundaries the opened project carries.
*/
let openableProjectForStub: OpenableProject = {
analysis: emptyAnalysis(),
analysisLanguages: ['en'],
};

jest.mock('../../components/modals/ProjectModals', () => ({
__esModule: true,
/**
Expand All @@ -293,6 +305,7 @@ jest.mock('../../components/modals/ProjectModals', () => ({
activeProject,
defaultAnalysisLanguage,
hasUnsavedWork,
loadFromProject,
onImportPt9,
onOpenImport,
openRequest,
Expand All @@ -304,7 +317,7 @@ jest.mock('../../components/modals/ProjectModals', () => ({
defaultAnalysisLanguage?: string;
hasUnsavedWork: boolean;
getDraftSnapshot: () => DraftProject | undefined;
loadFromProject: (project: unknown) => void;
loadFromProject: (project: OpenableProject) => void;
markSynced: () => void;
onImportPt9: () => void;
onOpenImport: (project: MockProject) => void;
Expand Down Expand Up @@ -366,6 +379,16 @@ jest.mock('../../components/modals/ProjectModals', () => ({
>
View info
</button>
<button
type="button"
data-testid="select-modal-open-project"
onClick={() => {
loadFromProject(openableProjectForStub);
setModal('none');
}}
>
Open project
</button>
</div>
)}
{modal === 'create' && (
Expand Down Expand Up @@ -535,13 +558,26 @@ function mockSettings(
});
}

/**
* Stubs {@link useLostBoundaryDismissal} to report the given lost anchors as undismissed.
*
* @returns The dismiss callback the stub hands the banner, so the wiring can be asserted on.
*/
function mockLostBoundaries(undismissedLostBoundaries: readonly string[]): jest.Mock {
const onDismiss = jest.fn();
jest.mocked(useLostBoundaryDismissal).mockReturnValue({ undismissedLostBoundaries, onDismiss });
return onDismiss;
}

describe('InterlinearizerLoader', () => {
beforeEach(() => {
capturedInterlinearizerProps = undefined;
capturedStoreProps = undefined;
interlinearizerMountCount = 0;
openableProjectForStub = { analysis: emptyAnalysis(), analysisLanguages: ['en'] };
mockBookData();
mockOptimisticSetting();
mockLostBoundaries([]);
// The loader's draft hook calls `interlinearizer.getDraft` on mount; default to a valid empty
// draft so the editor renders. Individual tests override with mockResolvedValueOnce.
mockSendCommand.mockResolvedValue(JSON.stringify(emptyDraft(testProjectId)));
Expand Down Expand Up @@ -1143,6 +1179,22 @@ describe('InterlinearizerLoader', () => {
expect(screen.getByTestId('pt9-copy-button')).toBeInTheDocument();
});

it('holds the banner back until the localized strings resolve', async () => {
mockImportCommands();
jest
.mocked(useLocalizedStrings)
.mockImplementation((keys: readonly string[]) => [
Object.fromEntries(keys.map((k) => [k, k])),
true,
]);

await act(async () =>
renderLoader({ useWebViewState: makeWebViewState({ activeProject: STUB_IMPORT_PROJECT }) }),
);

expect(screen.queryByTestId('pt9-import-banner')).not.toBeInTheDocument();
});

it('silences Save, Save As, and Wipe while an import is open', async () => {
mockImportCommands();
await renderImportView();
Expand Down Expand Up @@ -2541,6 +2593,115 @@ describe('InterlinearizerLoader', () => {
});
});

describe('lost segment boundaries', () => {
/**
* Renders the loader on a loaded book, parked on GEN unless a `scrRef` elsewhere asks for a
* cross-book swap.
*/
async function renderOnLoadedBook(scrRef?: SerializedVerseRef): Promise<void> {
mockBookData({ book: GEN_1_1_BOOK });
await act(async () => {
renderLoader({ useWebViewScrollGroupScrRef: makeScrollGroupHook(scrRef) });
});
}

it('shows the banner when the hook reports an undismissed loss', async () => {
mockLostBoundaries(['GEN 1:9:0']);

await renderOnLoadedBook();

expect(screen.getByTestId('lost-boundaries-banner')).toBeInTheDocument();
});

it('does not show the banner when the hook reports no loss', async () => {
await renderOnLoadedBook();

expect(screen.queryByTestId('lost-boundaries-banner')).not.toBeInTheDocument();
});

it('holds the banner back until the localized strings resolve', async () => {
jest
.mocked(useLocalizedStrings)
.mockImplementation((keys: readonly string[]) => [
Object.fromEntries(keys.map((k) => [k, k])),
true,
]);
mockLostBoundaries(['GEN 1:9:0']);

await renderOnLoadedBook();

expect(screen.queryByTestId('lost-boundaries-banner')).not.toBeInTheDocument();
});

it('holds the banner back during a cross-book swap', async () => {
// A swap is mid-flight when scrRef already names EXO but the loaded book is still GEN, whose
// anchors are the ones lost.
mockLostBoundaries(['GEN 1:9:0']);

await renderOnLoadedBook({ book: 'EXO', chapterNum: 1, verseNum: 1 });

expect(screen.queryByTestId('lost-boundaries-banner')).not.toBeInTheDocument();
});

it('interpolates the lost-anchor count into the banner text', async () => {
jest
.mocked(useLocalizedStrings)
.mockImplementation((keys: readonly string[]) => [
Object.fromEntries(
keys.map((k) => [
k,
k === '%interlinearizer_segmentation_lostBoundaries%' ? '{count} boundaries lost' : k,
]),
),
false,
]);
mockLostBoundaries(['GEN 1:9:0', 'GEN 1:1:99']);

await renderOnLoadedBook();

expect(screen.getByTestId('lost-boundaries-banner')).toHaveTextContent('2 boundaries lost');
});

it('uses the singular string for a single lost anchor', async () => {
jest
.mocked(useLocalizedStrings)
.mockImplementation((keys: readonly string[]) => [
Object.fromEntries(
keys.map((k) => [
k,
k === '%interlinearizer_segmentation_lostBoundaries_one%' ? 'just the one' : k,
]),
),
false,
]);
mockLostBoundaries(['GEN 1:9:0']);

await renderOnLoadedBook();

expect(screen.getByTestId('lost-boundaries-banner')).toHaveTextContent('just the one');
});

it('dismisses through the hook when the banner close button is clicked', async () => {
const onDismiss = mockLostBoundaries(['GEN 1:9:0']);
await renderOnLoadedBook();

await userEvent.click(screen.getByTestId('lost-boundaries-dismiss'));

expect(onDismiss).toHaveBeenCalledTimes(1);
});

it('leaves the draft untouched when the banner is dismissed', async () => {
mockLostBoundaries(['GEN 1:9:0']);
await renderOnLoadedBook();

await userEvent.click(screen.getByTestId('lost-boundaries-dismiss'));

// The banner is read-only: the anchors themselves stay for a source that reverts.
const saves = mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraft');
expect(saves).toHaveLength(0);
});
});

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 Expand Up @@ -3369,6 +3530,7 @@ describe('analysis store lifetime', () => {
interlinearizerMountCount = 0;
mockBookData();
mockOptimisticSetting();
mockLostBoundaries([]);
mockSendCommand.mockResolvedValue(JSON.stringify(emptyDraft(testProjectId)));
jest
.mocked(useData)
Expand Down
Loading
Loading