Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
},
"files.eol": "\n",

"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.format.enable": false,
"[javascript][javascriptreact]": {
"js/ts.validate.enabled": false
},
"js/ts.format.enabled": false,

"search.exclude": {
".git": true,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ The general file structure for an extension is as follows:
- `*.web-view.tsx` files will be treated as React WebViews
- `*.web-view.scss` files provide styles for WebViews
- `*.web-view.html` files are a conventional way to provide HTML WebViews (no special functionality)
- `src/__tests__/` contains unit tests (Jest) for the extension, including parser tests (valid and invalid XML, edge cases) and web-view tests
- `src/__tests__/` contains unit tests (Jest) for the extension, including parser tests (valid and invalid XML, edge cases) and WebView tests
- `__mocks__/` contains Jest mocks for the PAPI, file modules, and test fixtures used by tests in `src/__tests__/`. The `@papi/backend` and `@papi/frontend` mocks are used mutually exclusively (backend for main.ts tests, frontend for WebView tests); each mock file ends with `export {}` so TypeScript treats it as a module.
- `assets/` contains asset files the extension and its WebViews can retrieve using the `papi-extension:` protocol, as well as textual descriptions in various languages. It is copied into the build folder
- `assets/displayData.json` contains (optionally) a path to the extension's icon file as well as text for the extension's display name, short summary, and path to the full description file
Expand Down
28 changes: 26 additions & 2 deletions __mocks__/papi-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
* loading the real Platform API.
*/

const mockRegisterWebViewProvider = jest.fn().mockResolvedValue({ dispose: jest.fn() });
const mockOpenWebView = jest.fn().mockResolvedValue(undefined);
const mockRegisterWebViewProvider = jest.fn();
const mockRegisterCommand = jest.fn();
const mockOpenWebView = jest.fn();
const mockSelectProject = jest.fn();
const mockGetOpenWebViewDefinition = jest.fn();
const mockOnDidOpenWebView = jest.fn();
const mockOnDidCloseWebView = jest.fn();
const mockLogger = {
debug: jest.fn(),
error: jest.fn(),
Expand All @@ -13,18 +18,32 @@ const mockLogger = {
};

const papi = {
commands: {
registerCommand: mockRegisterCommand,
},
dialogs: {
selectProject: mockSelectProject,
},
webViewProviders: {
registerWebViewProvider: mockRegisterWebViewProvider,
},
webViews: {
openWebView: mockOpenWebView,
getOpenWebViewDefinition: mockGetOpenWebViewDefinition,
onDidOpenWebView: mockOnDidOpenWebView,
onDidCloseWebView: mockOnDidCloseWebView,
},
};

const defaultExport = {
...papi,
__mockRegisterWebViewProvider: mockRegisterWebViewProvider,
__mockRegisterCommand: mockRegisterCommand,
__mockOpenWebView: mockOpenWebView,
__mockSelectProject: mockSelectProject,
__mockGetOpenWebViewDefinition: mockGetOpenWebViewDefinition,
__mockOnDidOpenWebView: mockOnDidOpenWebView,
__mockOnDidCloseWebView: mockOnDidCloseWebView,
__mockLogger: mockLogger,
};

Expand All @@ -33,7 +52,12 @@ module.exports = {
default: defaultExport,
logger: mockLogger,
__mockRegisterWebViewProvider: mockRegisterWebViewProvider,
__mockRegisterCommand: mockRegisterCommand,
__mockOpenWebView: mockOpenWebView,
__mockSelectProject: mockSelectProject,
__mockGetOpenWebViewDefinition: mockGetOpenWebViewDefinition,
__mockOnDidOpenWebView: mockOnDidOpenWebView,
__mockOnDidCloseWebView: mockOnDidCloseWebView,
__mockLogger: mockLogger,
};

Expand Down
11 changes: 10 additions & 1 deletion __mocks__/platform-bible-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,13 @@ class UnsubscriberAsyncList {
}
}

export { UnsubscriberAsyncList };
/** Minimal PlatformError shape matching the real platform-bible-utils type. */
interface PlatformError {
message: string;
isPlatformError: true;
}

const isPlatformError = (value: unknown): value is PlatformError =>
typeof value === 'object' && value !== null && (value as PlatformError).isPlatformError === true;

export { UnsubscriberAsyncList, isPlatformError };
6 changes: 5 additions & 1 deletion contributions/localizedStrings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
{
"metadata": {},
"localizedStrings": {
"en": {}
"en": {
"%interlinearizer_dialog_open_title%": "Open Interlinearizer",
"%interlinearizer_dialog_open_prompt%": "Choose a project to open in the Interlinearizer:",
"%interlinearizer_openForProject%": "Open Interlinearizer for this Project"
}
}
}
23 changes: 22 additions & 1 deletion contributions/menus.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,26 @@
"groups": {},
"items": []
},
"webViewMenus": {}
"webViewMenus": {
"platformScriptureEditor.react": {
"topMenu": {
"columns": {},
"groups": {
"interlinearizer.editor": {
"column": "platformScriptureEditor.edit",
"order": 12
}
},
"items": [
{
"label": "%interlinearizer_openForProject%",
"localizeNotes": "Scripture Editor top menu > Open Interlinearizer for this project",
"group": "interlinearizer.editor",
"order": 1,
"command": "interlinearizer.openForWebView"
}
]
}
}
}
}
8 changes: 6 additions & 2 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@
import type { Config } from 'jest';

const config: Config = {
/** Automatically clear mock calls, instances, contexts and results before every test. */
clearMocks: true,
/**
* Reset mock implementations before every test (superset of clearMocks: also removes
* mockReturnValue/mockImplementation so implementations never leak between tests). Each test must
* set up the implementations it needs, typically in beforeEach.
*/
resetMocks: true,

/**
* Coverage only when run with --coverage (see npm run test:coverage). Omit for faster default
Expand Down
146 changes: 67 additions & 79 deletions src/__tests__/interlinearizer.web-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,7 @@
import type { WebViewProps } from '@papi/core';
import type { SerializedVerseRef } from '@sillsdev/scripture';
import { render, screen } from '@testing-library/react';
import { InterlinearXmlParser } from 'parsers/interlinearXmlParser';

/** Mock parser to allow overriding constructor behavior per test. */
jest.mock('parsers/interlinearXmlParser', () => {
const actual = jest.requireActual<typeof import('parsers/interlinearXmlParser')>(
'parsers/interlinearXmlParser',
);
return {
InterlinearXmlParser: jest.fn().mockImplementation(() => new actual.InterlinearXmlParser()),
};
});
import { useProjectData } from '@papi/frontend/react';

/**
* Load the WebView module; it assigns the component to globalThis.webViewComponent. This pattern is
Expand All @@ -32,94 +22,92 @@ if (!InterlinearizerWebView) throw new Error('webViewComponent not loaded');
/** Minimal SerializedVerseRef for hook mock return. */
const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 };

/** Full WebViewProps for tests; interlinearizer component ignores hooks/update. */
const testWebViewProps: WebViewProps = {
id: 'test-id',
webViewType: 'interlinearizer.mainWebView',
useWebViewState: <T,>(_key: string, defaultValue: T): [T, (v: T) => void, () => void] => [
defaultValue,
() => {},
() => {},
],
useWebViewScrollGroupScrRef: (): [
SerializedVerseRef,
(r: SerializedVerseRef) => void,
number | undefined,
(id: number | undefined) => void,
] => [defaultScrRef, () => {}, undefined, () => {}],
updateWebViewDefinition: () => true,
};
const testProjectId = 'test-project-id';

/** Builds a minimal WebViewProps for tests. */
function makeProps(projectId?: string): WebViewProps {
return {
id: 'test-id',
webViewType: 'interlinearizer.mainWebView',
projectId,
useWebViewState: <T,>(_key: string, defaultValue: T): [T, (v: T) => void, () => void] => [
defaultValue,
() => {},
() => {},
],
useWebViewScrollGroupScrRef: (): [
SerializedVerseRef,
(r: SerializedVerseRef) => void,
number | undefined,
(id: number | undefined) => void,
] => [defaultScrRef, () => {}, undefined, () => {}],
updateWebViewDefinition: () => true,
};
}

/** Configures useProjectData to return the given BookUSJ value and loading state this render. */
function mockBookData(value: unknown, isLoading = false): void {
jest.mocked(useProjectData).mockImplementation(() => ({
BookUSJ: () => [value, jest.fn(), isLoading],
}));
}

describe('InterlinearizerWebView', () => {
beforeEach(() => {
mockBookData(undefined);
});

it('renders the heading "Interlinearizer"', () => {
render(<InterlinearizerWebView {...testWebViewProps} />);
render(<InterlinearizerWebView {...makeProps()} />);

expect(screen.getByRole('heading', { name: /interlinearizer/i })).toBeInTheDocument();
});

it('renders the description mentioning test-data XML', () => {
render(<InterlinearizerWebView {...testWebViewProps} />);
it('shows a prompt to open from a project when no projectId is provided', () => {
render(<InterlinearizerWebView {...makeProps()} />);

expect(
screen.getByText(/raw json of the model parsed from/i, { exact: false }),
).toBeInTheDocument();
expect(screen.getByText(/test-data\/Interlinear_en_MAT\.xml/i)).toBeInTheDocument();
expect(screen.getByText(/open this webview from a paratext project/i)).toBeInTheDocument();
});

it('parses the bundled test XML and displays parsed JSON', () => {
render(<InterlinearizerWebView {...testWebViewProps} />);
it('shows the book and projectId when a project is linked', () => {
mockBookData({ type: 'USJ', version: '3.1', content: [] });
render(<InterlinearizerWebView {...makeProps(testProjectId)} />);

expect(screen.getByText(/parsed interlinear data \(json\)/i)).toBeInTheDocument();
expect(screen.getByText(/"GlossLanguage"/)).toBeInTheDocument();
expect(screen.getByText(/"BookId"/)).toBeInTheDocument();
expect(screen.getByText(new RegExp(testProjectId))).toBeInTheDocument();
expect(screen.getByText(/GEN · project/)).toBeInTheDocument();
});

it('displays parsed structure with expected verse data', () => {
render(<InterlinearizerWebView {...testWebViewProps} />);
it('shows Loading when projectId is set but book data has not arrived', () => {
mockBookData(undefined, true);
render(<InterlinearizerWebView {...makeProps(testProjectId)} />);

expect(screen.getByText(/"en"/)).toBeInTheDocument();
expect(screen.getByText(/"MAT"/)).toBeInTheDocument();
expect(screen.getByText('Loading…')).toBeInTheDocument();
});

it('does not show parse error when XML is valid', () => {
render(<InterlinearizerWebView {...testWebViewProps} />);
it('shows an error when no USJ book is available for the project', () => {
mockBookData(undefined, false);
render(<InterlinearizerWebView {...makeProps(testProjectId)} />);

expect(screen.queryByText(/^parse error$/i)).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: /error loading book/i })).toBeInTheDocument();
expect(screen.getByText(/no usj book available for gen in project/i)).toBeInTheDocument();
});

it('displays parse error when parser throws an Error (uses err.message)', () => {
const actual = jest.requireActual<typeof import('../parsers/interlinearXmlParser')>(
'../parsers/interlinearXmlParser',
);
const realInstance = new actual.InterlinearXmlParser();
const throwingParse = (): never => {
throw new Error('Invalid XML structure');
};
Object.defineProperty(realInstance, 'parse', { value: throwingParse, writable: true });
jest.mocked(InterlinearXmlParser).mockImplementationOnce(() => realInstance);

render(<InterlinearizerWebView {...testWebViewProps} />);

expect(screen.getByRole('heading', { name: /^parse error$/i })).toBeInTheDocument();
expect(screen.getByText(/invalid xml structure/i)).toBeInTheDocument();
it('shows the raw USFM when book data arrives', () => {
mockBookData({
type: 'USJ',
version: '3.1',
content: [{ type: 'book', marker: 'id', code: 'EXO' }],
});
render(<InterlinearizerWebView {...makeProps(testProjectId)} />);

expect(screen.getByText(/"code": "EXO"/)).toBeInTheDocument();
});

it('displays parse error when parser throws non-Error (uses String(err))', () => {
const actual = jest.requireActual<typeof import('../parsers/interlinearXmlParser')>(
'../parsers/interlinearXmlParser',
);
const realInstance = new actual.InterlinearXmlParser();
const throwingParse = (): never => {
// Intentionally throw a non-Error to test the String(err) branch in the catch block.
// eslint-disable-next-line no-throw-literal -- testing non-Error handling
throw 'plain string error';
};
Object.defineProperty(realInstance, 'parse', { value: throwingParse, writable: true });
jest.mocked(InterlinearXmlParser).mockImplementationOnce(() => realInstance);

render(<InterlinearizerWebView {...testWebViewProps} />);

expect(screen.getByRole('heading', { name: /^parse error$/i })).toBeInTheDocument();
expect(screen.getByText('plain string error')).toBeInTheDocument();
it('shows an error heading and message when book data is a PlatformError', () => {
mockBookData({ isPlatformError: true, message: 'Project not found' });
render(<InterlinearizerWebView {...makeProps(testProjectId)} />);

expect(screen.getByRole('heading', { name: /error loading book/i })).toBeInTheDocument();
expect(screen.getByText(/project not found/i)).toBeInTheDocument();
});
});
Loading
Loading