Skip to content

Commit 8662c00

Browse files
Update package configuration and enhance interlinearizer WebView functionality
- Add Node.js version requirement (>=18) to package.json and package-lock.json. - Improve interlinearizer WebView by implementing keyboard navigation for JSON view modes, allowing users to switch between modes using arrow keys. - Refactor related tests to ensure proper functionality of the new keyboard navigation feature. - Update README to reflect the new Node.js requirement and clarify usage of test data paths.
1 parent 44199a0 commit 8662c00

12 files changed

Lines changed: 308 additions & 93 deletions

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ The general file structure for an extension is as follows:
108108
- `assets/descriptions/description-<locale>.md` contains a brief description of the extension in the language specified by `<locale>`
109109
- `contributions/` contains JSON files the platform uses to extend data structures for things like menus and settings. The JSON files are referenced from the manifest
110110
- `public/` contains other static files that are copied into the build folder
111-
- `test-data/` contains sample interlinear XML (e.g. `Interlinear_en_MAT.xml`) for development and tests
111+
- `test-data/` contains sample interlinear XML (e.g. `Interlinear_en_MAT.xml`) for development and tests. In tests, resolve paths via `getTestDataPath('Interlinear_en_MAT.xml')` from `src/__tests__/test-helpers` rather than building paths with `..` segments.
112112
- `.github/` contains files to facilitate integration with GitHub
113113
- `.github/workflows` contains [GitHub Actions](https://github.com/features/actions) workflows for automating various processes in this repo (e.g. **Test** and **Lint** on push/PR to main, release-prep, hotfix-\*; **Publish** and **Bump Versions** manual dispatch; **CodeQL** for security)
114114
- `.github/assets/release-body.md` combined with a generated changelog becomes the body of [releases published using GitHub Actions](#publishing)
@@ -119,6 +119,10 @@ The general file structure for an extension is as follows:
119119
120120
## To install
121121

122+
### Requirements
123+
124+
- **Node.js >= 18** is required. The test suite uses the Web Crypto API (`globalThis.crypto.subtle`) for hashing in `paratext9Converter` tests (e.g. the `sha256HexWebCrypto` path in `src/__tests__/parsers/paratext-9/paratext9Converter.test.ts` when `convertParatext9ToInterlinearization` is called without the `hashSha256Hex` option). Node 18+ provides this API; older versions will cause those tests to fail. The same requirement is enforced in `package.json` via `engines.node` and is used by CI.
125+
122126
### Install dependencies:
123127

124128
1. Follow the instructions to install [`paranext-core`](https://github.com/paranext/paranext-core#developer-install). We recommend you clone `paranext-core` in the same parent directory in which you cloned this repository so you do not have to [reconfigure paths](#configure-paths-to-paranext-core-repo) to `paranext-core`.

package-lock.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
"types": "src/types/interlinearizer.d.ts",
77
"author": "SIL Global",
88
"license": "MIT",
9+
"engines": {
10+
"node": ">=18"
11+
},
912
"scripts": {
1013
"build:web-view": "webpack --config ./webpack/webpack.config.web-view.ts",
1114
"build:main": "webpack --config ./webpack/webpack.config.main.ts",

src/__tests__/interlinearizer.web-view.test.tsx

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import type { WebViewProps } from '@papi/core';
66
import type { SerializedVerseRef } from '@sillsdev/scripture';
77
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
8-
import type { InterlinearData } from 'paratext-9-types';
8+
import React from 'react';
9+
import type { InterlinearData } from 'parsers/paratext-9/paratext-9-types';
910

1011
/** Stub InterlinearData returned by the mocked parser. Matches shape the WebView displays. */
1112
const stubInterlinearData: InterlinearData = {
@@ -202,6 +203,21 @@ describe('InterlinearizerWebView', () => {
202203
expect(screen.getByText(/paratext-9/i)).toBeInTheDocument();
203204
});
204205

206+
it('Analyses view shows empty JSON pre when createAnalyses returns undefined', async () => {
207+
mockCreateAnalyses.mockReturnValueOnce(undefined);
208+
209+
const { container } = await renderWebView();
210+
fireEvent.click(screen.getByRole('radio', { name: /^analyses$/i }));
211+
await waitFor(() => {
212+
expect(screen.getByText(/^Analyses \(JSON\):$/)).toBeInTheDocument();
213+
});
214+
215+
const jsonPre = container.querySelector('pre');
216+
expect(jsonPre).toBeInTheDocument();
217+
expect(jsonPre).toBeEmptyDOMElement();
218+
expect(jsonPre).not.toHaveTextContent('undefined');
219+
});
220+
205221
it('renders empty JSON pre when jsonToShow is undefined (converter returns undefined)', async () => {
206222
mockConvert.mockResolvedValueOnce(undefined);
207223

@@ -243,4 +259,153 @@ describe('InterlinearizerWebView', () => {
243259
expect(jsonPre).toBeInTheDocument();
244260
expect(jsonPre).toBeEmptyDOMElement();
245261
});
262+
263+
describe('handleJsonViewModeKeyDown', () => {
264+
it('ArrowRight moves to next mode and updates selection', async () => {
265+
await renderWebView();
266+
const radiogroup = screen.getByRole('radiogroup', { name: /json view mode/i });
267+
expect(screen.getByText(/^InterlinearData \(JSON\):$/)).toBeInTheDocument();
268+
269+
await act(async () => {
270+
fireEvent.keyDown(radiogroup, { key: 'ArrowRight' });
271+
});
272+
273+
expect(screen.getByText(/^Interlinearization \(JSON\):$/)).toBeInTheDocument();
274+
expect(screen.getByRole('radio', { name: /^interlinearization$/i })).toHaveAttribute(
275+
'aria-checked',
276+
'true',
277+
);
278+
});
279+
280+
it('ArrowDown moves to next mode', async () => {
281+
await renderWebView();
282+
const radiogroup = screen.getByRole('radiogroup', { name: /json view mode/i });
283+
284+
await act(async () => {
285+
fireEvent.keyDown(radiogroup, { key: 'ArrowDown' });
286+
});
287+
expect(screen.getByText(/^Interlinearization \(JSON\):$/)).toBeInTheDocument();
288+
289+
await act(async () => {
290+
fireEvent.keyDown(radiogroup, { key: 'ArrowDown' });
291+
});
292+
expect(screen.getByText(/^Analyses \(JSON\):$/)).toBeInTheDocument();
293+
});
294+
295+
it('ArrowRight from last mode (Analyses) wraps to first (InterlinearData)', async () => {
296+
await renderWebView();
297+
const radiogroup = screen.getByRole('radiogroup', { name: /json view mode/i });
298+
fireEvent.click(screen.getByRole('radio', { name: /^analyses$/i }));
299+
expect(screen.getByText(/^Analyses \(JSON\):$/)).toBeInTheDocument();
300+
301+
await act(async () => {
302+
fireEvent.keyDown(radiogroup, { key: 'ArrowRight' });
303+
});
304+
305+
expect(screen.getByText(/^InterlinearData \(JSON\):$/)).toBeInTheDocument();
306+
expect(screen.getByRole('radio', { name: /^interlineardata$/i })).toHaveAttribute(
307+
'aria-checked',
308+
'true',
309+
);
310+
});
311+
312+
it('ArrowLeft moves to previous mode', async () => {
313+
await renderWebView();
314+
const radiogroup = screen.getByRole('radiogroup', { name: /json view mode/i });
315+
fireEvent.click(screen.getByRole('radio', { name: /^analyses$/i }));
316+
expect(screen.getByText(/^Analyses \(JSON\):$/)).toBeInTheDocument();
317+
318+
await act(async () => {
319+
fireEvent.keyDown(radiogroup, { key: 'ArrowLeft' });
320+
});
321+
322+
expect(screen.getByText(/^Interlinearization \(JSON\):$/)).toBeInTheDocument();
323+
expect(screen.getByRole('radio', { name: /^interlinearization$/i })).toHaveAttribute(
324+
'aria-checked',
325+
'true',
326+
);
327+
});
328+
329+
it('ArrowUp moves to previous mode', async () => {
330+
await renderWebView();
331+
const radiogroup = screen.getByRole('radiogroup', { name: /json view mode/i });
332+
fireEvent.click(screen.getByRole('radio', { name: /^interlinearization$/i }));
333+
334+
await act(async () => {
335+
fireEvent.keyDown(radiogroup, { key: 'ArrowUp' });
336+
});
337+
338+
expect(screen.getByText(/^InterlinearData \(JSON\):$/)).toBeInTheDocument();
339+
expect(screen.getByRole('radio', { name: /^interlineardata$/i })).toHaveAttribute(
340+
'aria-checked',
341+
'true',
342+
);
343+
});
344+
345+
it('ArrowLeft from first mode (InterlinearData) wraps to last (Analyses)', async () => {
346+
await renderWebView();
347+
const radiogroup = screen.getByRole('radiogroup', { name: /json view mode/i });
348+
expect(screen.getByText(/^InterlinearData \(JSON\):$/)).toBeInTheDocument();
349+
350+
await act(async () => {
351+
fireEvent.keyDown(radiogroup, { key: 'ArrowLeft' });
352+
});
353+
354+
expect(screen.getByText(/^Analyses \(JSON\):$/)).toBeInTheDocument();
355+
expect(screen.getByRole('radio', { name: /^analyses$/i })).toHaveAttribute(
356+
'aria-checked',
357+
'true',
358+
);
359+
});
360+
361+
it('non-arrow key does not change mode', async () => {
362+
await renderWebView();
363+
const radiogroup = screen.getByRole('radiogroup', { name: /json view mode/i });
364+
expect(screen.getByText(/^InterlinearData \(JSON\):$/)).toBeInTheDocument();
365+
366+
fireEvent.keyDown(radiogroup, { key: 'a' });
367+
fireEvent.keyDown(radiogroup, { key: 'Enter' });
368+
expect(screen.getByText(/^InterlinearData \(JSON\):$/)).toBeInTheDocument();
369+
expect(screen.getByRole('radio', { name: /^interlineardata$/i })).toHaveAttribute(
370+
'aria-checked',
371+
'true',
372+
);
373+
});
374+
375+
it('moves focus to the newly selected radio on arrow key', async () => {
376+
await renderWebView();
377+
const radiogroup = screen.getByRole('radiogroup', { name: /json view mode/i });
378+
const interlinearizationRadio = screen.getByRole('radio', {
379+
name: /^interlinearization$/i,
380+
});
381+
382+
await act(async () => {
383+
fireEvent.keyDown(radiogroup, { key: 'ArrowRight' });
384+
});
385+
386+
expect(document.activeElement).toBe(interlinearizationRadio);
387+
});
388+
389+
it('does nothing when current view mode is not in JSON_VIEW_MODES (idx === -1)', async () => {
390+
const setJsonViewMode = jest.fn();
391+
let useStateCallCount = 0;
392+
const useStateSpy = jest.spyOn(React, 'useState').mockImplementation(() => {
393+
useStateCallCount += 1;
394+
return useStateCallCount === 1 ? ['invalid', setJsonViewMode] : [undefined, jest.fn()];
395+
});
396+
397+
try {
398+
await renderWebView();
399+
const radiogroup = screen.getByRole('radiogroup', { name: /json view mode/i });
400+
401+
await act(async () => {
402+
fireEvent.keyDown(radiogroup, { key: 'ArrowRight' });
403+
});
404+
405+
expect(setJsonViewMode).not.toHaveBeenCalled();
406+
} finally {
407+
useStateSpy.mockRestore();
408+
}
409+
});
410+
});
246411
});

src/__tests__/parsers/paratext-9/paratext9Converter.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
/// <reference types="jest" />
66

77
import { createHash } from 'crypto';
8-
import type { InterlinearData } from 'paratext-9-types';
98
import {
109
convertParatext9ToInterlinearization,
1110
createAnalyses,
1211
} from 'parsers/paratext-9/paratext9Converter';
12+
import type { InterlinearData } from 'parsers/paratext-9/paratext-9-types';
1313

1414
/** SHA-256 hex hasher using Node crypto. */
1515
function nodeSha256Hex(str: string): Promise<string> {

src/__tests__/parsers/paratext-9/paratext9Parser.test.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
/// <reference types="jest" />
33

44
import * as fs from 'fs';
5-
import * as path from 'path';
65

76
import { Paratext9Parser } from 'parsers/paratext-9/paratext9Parser';
7+
import { getTestDataPath } from '../../test-helpers';
88

99
describe('Paratext9Parser', () => {
1010
let parser: Paratext9Parser;
@@ -575,15 +575,7 @@ describe('Paratext9Parser', () => {
575575
});
576576

577577
it('parses real test-data file without throwing', () => {
578-
const xmlPath = path.join(
579-
__dirname,
580-
'..',
581-
'..',
582-
'..',
583-
'..',
584-
'test-data',
585-
'Interlinear_en_MAT.xml',
586-
);
578+
const xmlPath = getTestDataPath('Interlinear_en_MAT.xml');
587579
const xml = fs.readFileSync(xmlPath, 'utf-8');
588580
const result = parser.parse(xml);
589581

src/__tests__/test-helpers.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
/**
22
* @file Test helpers used to build type-safe mocks without type assertions. Provides a minimal
3-
* ExecutionActivationContext that satisfies @papi/core types.
3+
* ExecutionActivationContext that satisfies @papi/core types, and a stable path resolver for the
4+
* test-data directory.
45
*/
6+
import * as path from 'path';
7+
58
import type { ExecutionActivationContext } from '@papi/core';
69
import { UnsubscriberAsyncList } from 'platform-bible-utils';
710

11+
/**
12+
* Resolves a path to a file under the project's test-data directory.
13+
*
14+
* @param relativePath - Filename or path relative to test-data (e.g. 'Interlinear_en_MAT.xml').
15+
* @returns Absolute path to the file under test-data.
16+
*/
17+
export function getTestDataPath(relativePath: string): string {
18+
return path.resolve(__dirname, '..', '..', 'test-data', relativePath);
19+
}
20+
821
/** Minimal execution token-shaped object for tests (structural match for ExecutionToken). */
922
const mockExecutionToken: {
1023
type: 'extension';

0 commit comments

Comments
 (0)