Skip to content

Commit e5c7fe8

Browse files
Open fast, indicate the probe, and make the offer report one door
1 parent f2e9280 commit e5c7fe8

15 files changed

Lines changed: 312 additions & 106 deletions

__mocks__/platform-bible-react.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,11 @@ export function RadioGroupItem({
623623
* {@link DialogContent} and {@link DialogTitle}, mirroring how the real Radix-based component
624624
* reaches its parts from the root.
625625
*/
626+
/** Stub spinner: a marker element standing in for the platform's indeterminate spinner. */
627+
export function Spinner({ className }: { className?: string }) {
628+
return <span className={className} data-testid="spinner" />;
629+
}
630+
626631
const DialogContext = createContext<{ onOpenChange?: (open: boolean) => void; titleId?: string }>(
627632
{},
628633
);

contributions/localizedStrings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@
169169
"%interlinearizer_pt9ConvertPrompt_message%": "This project has Paratext 9 interlinear data. Would you like to convert it now?",
170170
"%interlinearizer_pt9ConvertPrompt_yes%": "Yes",
171171
"%interlinearizer_pt9ConvertPrompt_no%": "No",
172+
"%interlinearizer_pt9ConvertPrompt_checking%": "Checking for Paratext 9 interlinear data…",
172173
"%interlinearizer_pt9ImportModal_title%": "Import from Paratext 9",
173174
"%interlinearizer_pt9ImportModal_syncTitle%": "Sync from Paratext 9",
174175
"%interlinearizer_pt9ImportModal_importing%": "Importing from Paratext 9…",

src/__tests__/components/InterlinearizerLoader.test.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,7 +1151,15 @@ describe('InterlinearizerLoader', () => {
11511151
expect(screen.queryByTestId('wipe-modal-panel')).not.toBeInTheDocument();
11521152
});
11531153

1154+
/** Points the frontend PDP mock at a manifest, so the offer probe answers `available`. */
1155+
function mockOfferProbe(manifest: Record<string, string> = { 'Interlinear_en/x.xml': 'h' }) {
1156+
mockPdpGet.mockResolvedValue({
1157+
getPt9InterlinearManifest: jest.fn().mockResolvedValue(manifest),
1158+
});
1159+
}
1160+
11541161
it('offers the first-open conversion and runs the import on Yes', async () => {
1162+
mockOfferProbe();
11551163
mockImportCommands({
11561164
importResult: { outcome: 'imported', projectId: 'import-1', report: IMPORT_REPORT },
11571165
});
@@ -1173,6 +1181,7 @@ describe('InterlinearizerLoader', () => {
11731181
});
11741182

11751183
it('returns to the plain view when an offer-run report is closed', async () => {
1184+
mockOfferProbe();
11761185
mockImportCommands({
11771186
importResult: { outcome: 'imported', projectId: 'import-1', report: IMPORT_REPORT },
11781187
});
@@ -1192,6 +1201,7 @@ describe('InterlinearizerLoader', () => {
11921201
});
11931202

11941203
it('persists the empty draft and runs no import on No', async () => {
1204+
mockOfferProbe();
11951205
mockImportCommands();
11961206
await act(async () => {
11971207
renderLoader({ useWebViewState: makeWebViewState({ offerPt9Import: true }) });
@@ -1214,6 +1224,7 @@ describe('InterlinearizerLoader', () => {
12141224
});
12151225

12161226
it('holds the offer while the draft is still loading', async () => {
1227+
mockOfferProbe();
12171228
mockSendCommand.mockImplementation(() => new Promise(() => {}));
12181229
await act(async () => {
12191230
renderLoader({ useWebViewState: makeWebViewState({ offerPt9Import: true }) });
@@ -1222,7 +1233,61 @@ describe('InterlinearizerLoader', () => {
12221233
expect(screen.queryByTestId('pt9-convert-prompt-message')).not.toBeInTheDocument();
12231234
});
12241235

1236+
it('never offers when the probe finds no convertible data', async () => {
1237+
mockOfferProbe({});
1238+
mockImportCommands();
1239+
await act(async () => {
1240+
renderLoader({ useWebViewState: makeWebViewState({ offerPt9Import: true }) });
1241+
});
1242+
1243+
expect(screen.queryByTestId('pt9-convert-prompt-message')).not.toBeInTheDocument();
1244+
expect(screen.queryByTestId('pt9-checking')).not.toBeInTheDocument();
1245+
});
1246+
1247+
it('shows the checking status only when the probe is still unanswered after the delay', async () => {
1248+
jest.useFakeTimers();
1249+
try {
1250+
mockPdpGet.mockResolvedValue({
1251+
getPt9InterlinearManifest: jest.fn(() => new Promise(() => {})),
1252+
});
1253+
mockImportCommands();
1254+
await act(async () => {
1255+
renderLoader({ useWebViewState: makeWebViewState({ offerPt9Import: true }) });
1256+
});
1257+
expect(screen.queryByTestId('pt9-checking')).not.toBeInTheDocument();
1258+
1259+
await act(async () => {
1260+
jest.advanceTimersByTime(400);
1261+
});
1262+
1263+
expect(screen.getByTestId('pt9-checking')).toBeInTheDocument();
1264+
} finally {
1265+
jest.useRealTimers();
1266+
}
1267+
});
1268+
1269+
it('never flashes the checking status when the probe answers fast', async () => {
1270+
jest.useFakeTimers();
1271+
try {
1272+
mockOfferProbe();
1273+
mockImportCommands();
1274+
await act(async () => {
1275+
renderLoader({ useWebViewState: makeWebViewState({ offerPt9Import: true }) });
1276+
});
1277+
1278+
await act(async () => {
1279+
jest.advanceTimersByTime(400);
1280+
});
1281+
1282+
expect(screen.queryByTestId('pt9-checking')).not.toBeInTheDocument();
1283+
expect(screen.getByTestId('pt9-convert-prompt-message')).toBeInTheDocument();
1284+
} finally {
1285+
jest.useRealTimers();
1286+
}
1287+
});
1288+
12251289
it('hides the offer behind an open modal', async () => {
1290+
mockOfferProbe();
12261291
mockImportCommands({
12271292
importResult: { outcome: 'imported', projectId: 'import-1', report: IMPORT_REPORT },
12281293
});

src/__tests__/components/modals/Pt9ConvertPromptModal.test.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@
44
import { render, screen } from '@testing-library/react';
55
import userEvent from '@testing-library/user-event';
66
import { useLocalizedStrings } from '@papi/frontend/react';
7-
import { Pt9ConvertPromptModal } from '../../../components/modals/Pt9ConvertPromptModal';
7+
import {
8+
Pt9CheckingModal,
9+
Pt9ConvertPromptModal,
10+
} from '../../../components/modals/Pt9ConvertPromptModal';
811

912
const LOCALIZED: Record<string, string> = {
1013
'%interlinearizer_pt9ImportModal_title%': 'Import from Paratext 9',
1114
'%interlinearizer_pt9ConvertPrompt_message%':
1215
'This project has Paratext 9 interlinear data. Would you like to convert it now?',
1316
'%interlinearizer_pt9ConvertPrompt_yes%': 'Yes',
1417
'%interlinearizer_pt9ConvertPrompt_no%': 'No',
18+
'%interlinearizer_pt9ConvertPrompt_checking%': 'Checking for Paratext 9 interlinear data…',
1519
};
1620

1721
describe('Pt9ConvertPromptModal', () => {
@@ -50,3 +54,19 @@ describe('Pt9ConvertPromptModal', () => {
5054
expect(onNo).toHaveBeenCalledTimes(1);
5155
});
5256
});
57+
58+
describe('Pt9CheckingModal', () => {
59+
beforeEach(() => {
60+
jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]);
61+
});
62+
63+
it('shows the spinner and the checking status with no dismiss affordances', () => {
64+
render(<Pt9CheckingModal />);
65+
66+
expect(screen.getByTestId('pt9-checking')).toHaveTextContent(
67+
'Checking for Paratext 9 interlinear data…',
68+
);
69+
expect(screen.getByTestId('spinner')).toBeInTheDocument();
70+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
71+
});
72+
});

src/__tests__/components/modals/Pt9ImportModal.test.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ function makeReport(): Pt9ImportReport {
5656
clustersTotal: 20,
5757
clustersConverted: 15,
5858
phrasesConverted: 2,
59-
clusterDrops: { ...emptyDrops, formMismatch: 4, verseNotFound: 1 },
59+
clusterDrops: { ...emptyDrops, formMismatch: 4, verseNotFound: 1, duplicateCluster: 1 },
6060
ambiguousAnchors: 0,
6161
punctuationEntriesIgnored: 0,
6262
},
@@ -160,7 +160,9 @@ describe('Pt9ImportModal', () => {
160160
expect(report).toHaveTextContent('Languages: en, fr');
161161
expect(report).toHaveTextContent('Books: MAT, MRK');
162162
expect(report).toHaveTextContent('15 of 25 clusters, 2 phrases');
163-
expect(report).toHaveTextContent('10 clusters: 6 verse not found; 4 did not match the text');
163+
expect(report).toHaveTextContent('11 clusters: 6 verse not found; 4 did not match the text');
164+
// Only the top two reasons are named; the third stays in the JSON report.
165+
expect(report).not.toHaveTextContent('duplicate data');
164166
expect(report).toHaveTextContent('Books with no text: MRK');
165167
});
166168

@@ -205,6 +207,22 @@ describe('Pt9ImportModal', () => {
205207
expect(onOpen).toHaveBeenCalled();
206208
});
207209

210+
it('offers a single Open on an offer-run report and fires it', async () => {
211+
const onOpen = jest.fn();
212+
render(
213+
<Pt9ImportModal
214+
phase={{ kind: 'report', report: makeReport() }}
215+
mode="offer"
216+
onOpen={onOpen}
217+
onClose={jest.fn()}
218+
/>,
219+
);
220+
221+
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
222+
await userEvent.click(screen.getByRole('button', { name: 'Open' }));
223+
expect(onOpen).toHaveBeenCalled();
224+
});
225+
208226
it('offers only Close on a sync report', () => {
209227
render(
210228
<Pt9ImportModal

src/__tests__/hooks/usePt9ImportAvailability.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { renderHook, waitFor } from '@testing-library/react';
44
import papi from '@papi/frontend';
5-
import usePt9ImportAvailability from '../../hooks/usePt9ImportAvailability';
5+
import usePt9ImportAvailability, { usePt9ImportProbe } from '../../hooks/usePt9ImportAvailability';
66
import { getMockedPdpGet, makeStubProject } from '../test-helpers';
77

88
const mockPdpGet = getMockedPdpGet(papi);
@@ -58,6 +58,22 @@ describe('usePt9ImportAvailability', () => {
5858
expect(result.current).toBe(false);
5959
});
6060

61+
it('returns to false when a re-probe fails after an earlier success', async () => {
62+
mockManifest({ 'Lexicon.xml': 'aaaa1111' });
63+
const { result, rerender } = renderHook(
64+
({ loading }) => usePt9ImportAvailability('src-project', [], loading),
65+
{ initialProps: { loading: false } },
66+
);
67+
await waitFor(() => expect(result.current).toBe(true));
68+
69+
rerender({ loading: true });
70+
mockPdpGet.mockRejectedValue(new Error('probe failed'));
71+
rerender({ loading: false });
72+
73+
await waitFor(() => expect(mockPdpGet).toHaveBeenCalledTimes(2));
74+
expect(result.current).toBe(false);
75+
});
76+
6177
it('ignores a probe that lands after unmount', async () => {
6278
let resolveManifest: (m: Record<string, string>) => void = () => {};
6379
mockPdpGet.mockResolvedValue({
@@ -75,3 +91,37 @@ describe('usePt9ImportAvailability', () => {
7591
// update after unmount would emit one) is the observable behavior.
7692
});
7793
});
94+
95+
describe('usePt9ImportProbe', () => {
96+
it('moves from pending to available when the manifest lists files', async () => {
97+
mockManifest({ 'Lexicon.xml': 'aaaa1111' });
98+
99+
const { result } = renderHook(() => usePt9ImportProbe('src-project', true));
100+
101+
expect(result.current).toBe('pending');
102+
await waitFor(() => expect(result.current).toBe('available'));
103+
});
104+
105+
it('reports unavailable for an empty manifest', async () => {
106+
mockManifest({});
107+
108+
const { result } = renderHook(() => usePt9ImportProbe('src-project', true));
109+
110+
await waitFor(() => expect(result.current).toBe('unavailable'));
111+
});
112+
113+
it('reports unavailable when the probe fails', async () => {
114+
mockPdpGet.mockRejectedValue(new Error('no such projectInterface'));
115+
116+
const { result } = renderHook(() => usePt9ImportProbe('src-project', true));
117+
118+
await waitFor(() => expect(result.current).toBe('unavailable'));
119+
});
120+
121+
it('stays pending and never probes while disabled', () => {
122+
const { result } = renderHook(() => usePt9ImportProbe('src-project', false));
123+
124+
expect(result.current).toBe('pending');
125+
expect(mockPdpGet).not.toHaveBeenCalled();
126+
});
127+
});

src/__tests__/main.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -450,12 +450,12 @@ describe('main', () => {
450450
webViewType: 'someExtension.view',
451451
projectId: 'project-from-webview',
452452
});
453-
jest.mocked(pt9ImportService.shouldOfferPt9Import).mockResolvedValue(true);
453+
jest.mocked(pt9ImportService.hasNoInterlinearizerState).mockResolvedValue(true);
454454
const openForWebView = await getOpenForWebViewHandler();
455455

456456
await openForWebView('some-webview');
457457

458-
expect(jest.mocked(pt9ImportService.shouldOfferPt9Import)).toHaveBeenCalledWith(
458+
expect(jest.mocked(pt9ImportService.hasNoInterlinearizerState)).toHaveBeenCalledWith(
459459
expect.anything(),
460460
'project-from-webview',
461461
);
@@ -628,7 +628,7 @@ describe('main', () => {
628628
);
629629
});
630630

631-
it('does not probe for the convert offer when reusing an existing tab', async () => {
631+
it('does not compute the convert offer when reusing an existing tab', async () => {
632632
__mockSelectProject.mockResolvedValue('my-project');
633633
const context = createTestActivationContext();
634634
await activate(context);
@@ -638,7 +638,7 @@ describe('main', () => {
638638

639639
await findRegisteredHandler('interlinearizer.openForWebView')?.();
640640

641-
expect(jest.mocked(pt9ImportService.shouldOfferPt9Import)).not.toHaveBeenCalled();
641+
expect(jest.mocked(pt9ImportService.hasNoInterlinearizerState)).not.toHaveBeenCalled();
642642
expect(__mockOpenWebView).toHaveBeenCalledWith(
643643
mainWebViewType,
644644
undefined,

src/__tests__/services/pt9ImportService.test.ts

Lines changed: 15 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as path from 'node:path';
55

66
import papiBackendMock from '@papi/backend';
77
import type { Pt9InterlinearProjectData } from 'platform-scripture';
8-
import { importPt9Project, shouldOfferPt9Import } from '../../services/pt9ImportService';
8+
import { hasNoInterlinearizerState, importPt9Project } from '../../services/pt9ImportService';
99
import { resetQueuesForTesting } from '../../services/projectStorage';
1010
import { createTestActivationContext, enoentError, makeStubProject } from '../test-helpers';
1111

@@ -281,67 +281,47 @@ describe('importPt9Project', () => {
281281
});
282282
});
283283

284-
describe('shouldOfferPt9Import', () => {
284+
describe('hasNoInterlinearizerState', () => {
285285
beforeEach(() => {
286286
resetQueuesForTesting();
287287
});
288288

289289
/** Seeds storage reads by key; unlisted keys read as never written. */
290290
function seedStorage(entries: Record<string, string>): void {
291-
__mockReadUserData.mockImplementation(async (_token: unknown, key: string) => {
292-
if (Object.hasOwn(entries, key)) return entries[key];
291+
__mockReadUserData.mockImplementation(async (_token: unknown, key: unknown) => {
292+
if (typeof key === 'string' && Object.hasOwn(entries, key)) return entries[key];
293293
throw enoentError();
294294
});
295295
}
296296

297-
/** Points the PT9 PDP mock at a manifest carrying the given file paths. */
298-
function seedManifest(paths: string[]): void {
299-
__mockProjectDataProvidersGet.mockResolvedValue({
300-
getPt9InterlinearManifest: jest
301-
.fn()
302-
.mockResolvedValue(Object.fromEntries(paths.map((filePath) => [filePath, 'hash']))),
303-
});
304-
}
305-
306-
it('offers when nothing is stored and the source has an interlinear book file', async () => {
297+
it('answers true when the source has no draft and no projects', async () => {
307298
seedStorage({});
308-
seedManifest(['Lexicon.xml', 'Interlinear_en/Interlinear_en_MAT.xml']);
309299

310-
await expect(shouldOfferPt9Import(token, 'src-project')).resolves.toBe(true);
300+
await expect(hasNoInterlinearizerState(token, 'src-project')).resolves.toBe(true);
311301
});
312302

313-
it('does not offer when a draft is already stored', async () => {
303+
it('answers false when a draft is already stored', async () => {
314304
seedStorage({ 'draft:src-project': 'anything' });
315305

316-
await expect(shouldOfferPt9Import(token, 'src-project')).resolves.toBe(false);
317-
expect(__mockProjectDataProvidersGet).not.toHaveBeenCalled();
306+
await expect(hasNoInterlinearizerState(token, 'src-project')).resolves.toBe(false);
318307
});
319308

320-
it('does not offer when a project already exists for the source', async () => {
309+
it('answers false when a project already exists for the source', async () => {
321310
seedStorage({
322311
projectIds: JSON.stringify(['p1']),
323312
'project:p1': JSON.stringify(makeStubProject('p1')),
324313
});
325314

326-
await expect(shouldOfferPt9Import(token, 'src-project')).resolves.toBe(false);
327-
expect(__mockProjectDataProvidersGet).not.toHaveBeenCalled();
328-
});
329-
330-
it('does not offer for a lexicon- or word-analyses-only manifest', async () => {
331-
seedStorage({});
332-
seedManifest(['Lexicon.xml', 'WordAnalyses.xml']);
333-
334-
await expect(shouldOfferPt9Import(token, 'src-project')).resolves.toBe(false);
315+
await expect(hasNoInterlinearizerState(token, 'src-project')).resolves.toBe(false);
335316
});
336317

337-
it('answers false when the probe fails, and only warns', async () => {
338-
seedStorage({});
339-
__mockProjectDataProvidersGet.mockRejectedValue(new Error('interface unsupported'));
318+
it('answers false when the state check fails, and only warns', async () => {
319+
__mockReadUserData.mockRejectedValue(new Error('storage unavailable'));
340320

341-
await expect(shouldOfferPt9Import(token, 'src-project')).resolves.toBe(false);
321+
await expect(hasNoInterlinearizerState(token, 'src-project')).resolves.toBe(false);
342322
expect(__mockLogger.warn).toHaveBeenCalledWith(
343-
'Interlinearizer: Paratext 9 convert-offer probe failed; not offering',
344-
expect.objectContaining({ message: 'interface unsupported' }),
323+
'Interlinearizer: Paratext 9 convert-offer state check failed; not offering',
324+
expect.objectContaining({ message: 'storage unavailable' }),
345325
);
346326
});
347327
});

0 commit comments

Comments
 (0)