Skip to content

Commit ade5ac1

Browse files
Add SHA-256 hash-based text version generation and converter support
- Introduce SHA-256 hashing for consistent book-level text version generation across Node and WebView environments. - Add Web Crypto-based sha256HexWebCrypto for WebView-safe hashing; support injectable hashSha256Hex in converter options for Node (e.g. paranext-core generateHashFromBuffer). - Compute book text version from sorted, concatenated verse hashes via computeBookTextVersion. - Update paratext9Converter and tests to align with hash-generation behavior and remove obsolete code. - Refactor interlinearizer WebView to use useEffect for async conversion and improve JSON view mode buttons. - Update documentation for data structures and types. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 816ea6e commit ade5ac1

5 files changed

Lines changed: 303 additions & 135 deletions

File tree

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

Lines changed: 68 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import type { WebViewProps } from '@papi/core';
66
import type { SerializedVerseRef } from '@sillsdev/scripture';
7-
import { fireEvent, render, screen } from '@testing-library/react';
7+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
88
import type { InterlinearData } from 'paratext-9-types';
99

1010
/** Stub InterlinearData returned by the mocked parser. Matches shape the WebView displays. */
@@ -23,7 +23,7 @@ const stubInterlinearization = {
2323
};
2424

2525
const mockParse = jest.fn().mockReturnValue(stubInterlinearData);
26-
const mockConvert = jest.fn().mockReturnValue(stubInterlinearization);
26+
const mockConvert = jest.fn().mockResolvedValue(stubInterlinearization);
2727

2828
/** Stub analyses map for Analyses view (ID → Analysis). */
2929
const stubAnalysesMap = new Map([
@@ -88,24 +88,40 @@ const testWebViewProps: WebViewProps = {
8888
updateWebViewDefinition: () => true,
8989
};
9090

91+
/**
92+
* Renders the WebView and waits for the mount effect's async conversion to settle inside act(). The
93+
* component calls convertParatext9ToInterlinearization(parsed) in useEffect; when the promise
94+
* resolves it calls setInterlinearization. Without waiting, that update runs after the test and
95+
* triggers "An update to ... was not wrapped in act(...)". This helper flushes the async work so
96+
* all state updates are wrapped.
97+
*/
98+
async function renderWebView(): Promise<ReturnType<typeof render>> {
99+
return act(async () => {
100+
const result = render(<InterlinearizerWebView {...testWebViewProps} />);
101+
await Promise.resolve();
102+
await Promise.resolve();
103+
return result;
104+
});
105+
}
106+
91107
describe('InterlinearizerWebView', () => {
92-
it('renders the heading "Interlinearizer"', () => {
93-
render(<InterlinearizerWebView {...testWebViewProps} />);
108+
it('renders the heading "Interlinearizer"', async () => {
109+
await renderWebView();
94110

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

98-
it('renders the description mentioning test-data XML', () => {
99-
render(<InterlinearizerWebView {...testWebViewProps} />);
114+
it('renders the description mentioning test-data XML', async () => {
115+
await renderWebView();
100116

101117
expect(
102118
screen.getByText(/raw json of the model parsed from/i, { exact: false }),
103119
).toBeInTheDocument();
104120
expect(screen.getByText(/test-data\/Interlinear_en_MAT\.xml/i)).toBeInTheDocument();
105121
});
106122

107-
it('renders the JSON view mode switch (InterlinearData / Interlinearization / Analyses)', () => {
108-
render(<InterlinearizerWebView {...testWebViewProps} />);
123+
it('renders the JSON view mode switch (InterlinearData / Interlinearization / Analyses)', async () => {
124+
await renderWebView();
109125

110126
const radiogroup = screen.getByRole('radiogroup', { name: /json view mode/i });
111127
expect(radiogroup).toBeInTheDocument();
@@ -115,64 +131,67 @@ describe('InterlinearizerWebView', () => {
115131
expect(screen.getByText(/view json as:/i)).toBeInTheDocument();
116132
});
117133

118-
it('displays InterlinearData JSON by default when parser returns data', () => {
119-
render(<InterlinearizerWebView {...testWebViewProps} />);
134+
it('displays InterlinearData JSON by default when parser returns data', async () => {
135+
await renderWebView();
120136

121137
expect(screen.getByText(/^InterlinearData \(JSON\):$/)).toBeInTheDocument();
122138
expect(screen.getByText(/glossLanguage/i)).toBeInTheDocument();
123139
expect(screen.getByText(/bookId/i)).toBeInTheDocument();
124140
});
125141

126-
it('displays parsed structure including glossLanguage and bookId values', () => {
127-
render(<InterlinearizerWebView {...testWebViewProps} />);
142+
it('displays parsed structure including glossLanguage and bookId values', async () => {
143+
await renderWebView();
128144

129145
expect(screen.getByText(/"en"/)).toBeInTheDocument();
130146
expect(screen.getByText(/"MAT"/)).toBeInTheDocument();
131147
});
132148

133-
it('does not show parse error when parser succeeds', () => {
134-
render(<InterlinearizerWebView {...testWebViewProps} />);
149+
it('does not show parse error when parser succeeds', async () => {
150+
await renderWebView();
135151

136152
expect(screen.queryByText(/^parse error$/i)).not.toBeInTheDocument();
137153
});
138154

139-
it('displays parse error when parser throws an Error (uses err.message)', () => {
155+
it('displays parse error when parser throws an Error (uses err.message)', async () => {
140156
mockParse.mockImplementationOnce(() => {
141157
throw new Error('Invalid XML structure');
142158
});
143159

144-
render(<InterlinearizerWebView {...testWebViewProps} />);
160+
await renderWebView();
145161

146162
expect(screen.getByRole('heading', { name: /^parse error$/i })).toBeInTheDocument();
147163
expect(screen.getByText(/invalid xml structure/i)).toBeInTheDocument();
148164
});
149165

150-
it('switching to Interlinearization shows converted model JSON', () => {
151-
render(<InterlinearizerWebView {...testWebViewProps} />);
166+
it('switching to Interlinearization shows converted model JSON', async () => {
167+
await renderWebView();
152168

153169
fireEvent.click(screen.getByRole('radio', { name: /^interlinearization$/i }));
154170

155171
expect(screen.getByText(/^Interlinearization \(JSON\):$/)).toBeInTheDocument();
156-
expect(screen.getByText(/analysisLanguages/i)).toBeInTheDocument();
157-
expect(screen.getByText(/sourceWritingSystem/i)).toBeInTheDocument();
158-
expect(screen.getByText(/segments/i)).toBeInTheDocument();
172+
await waitFor(() => {
173+
expect(screen.getByText(/analysisLanguages/i)).toBeInTheDocument();
174+
expect(screen.getByText(/sourceWritingSystem/i)).toBeInTheDocument();
175+
expect(screen.getByText(/segments/i)).toBeInTheDocument();
176+
});
159177
});
160178

161-
it('switching back to InterlinearData shows PT9 structure JSON', () => {
162-
render(<InterlinearizerWebView {...testWebViewProps} />);
179+
it('switching back to InterlinearData shows PT9 structure JSON', async () => {
180+
await renderWebView();
163181

164182
fireEvent.click(screen.getByRole('radio', { name: /^interlinearization$/i }));
165-
expect(screen.getByText(/^Interlinearization \(JSON\):$/)).toBeInTheDocument();
166-
183+
await waitFor(() => {
184+
expect(screen.getByText(/^Interlinearization \(JSON\):$/)).toBeInTheDocument();
185+
});
167186
fireEvent.click(screen.getByRole('radio', { name: /^interlineardata$/i }));
168187

169188
expect(screen.getByText(/^InterlinearData \(JSON\):$/)).toBeInTheDocument();
170189
expect(screen.getByText(/glossLanguage/i)).toBeInTheDocument();
171190
expect(screen.getByText(/bookId/i)).toBeInTheDocument();
172191
});
173192

174-
it('switching to Analyses shows analysis map JSON from test data', () => {
175-
render(<InterlinearizerWebView {...testWebViewProps} />);
193+
it('switching to Analyses shows analysis map JSON from test data', async () => {
194+
await renderWebView();
176195

177196
fireEvent.click(screen.getByRole('radio', { name: /^analyses$/i }));
178197

@@ -183,28 +202,45 @@ describe('InterlinearizerWebView', () => {
183202
expect(screen.getByText(/paratext-9/i)).toBeInTheDocument();
184203
});
185204

186-
it('renders empty JSON pre when jsonToShow is undefined (converter returns undefined)', () => {
187-
mockConvert.mockReturnValueOnce(undefined);
205+
it('renders empty JSON pre when jsonToShow is undefined (converter returns undefined)', async () => {
206+
mockConvert.mockResolvedValueOnce(undefined);
188207

189-
const { container } = render(<InterlinearizerWebView {...testWebViewProps} />);
208+
const { container } = await renderWebView();
190209
fireEvent.click(screen.getByRole('radio', { name: /^interlinearization$/i }));
210+
await waitFor(() => {
211+
expect(container.querySelector('pre')).toBeInTheDocument();
212+
});
191213

192214
const jsonPre = container.querySelector('pre');
193215
expect(jsonPre).toBeInTheDocument();
194216
expect(jsonPre).toBeEmptyDOMElement();
195217
expect(jsonPre).not.toHaveTextContent('undefined');
196218
});
197219

198-
it('displays parse error when parser throws non-Error (uses String(err))', () => {
220+
it('displays parse error when parser throws non-Error (uses String(err))', async () => {
199221
mockParse.mockImplementationOnce(() => {
200222
// Intentionally throw a non-Error to test the String(err) branch in the catch block.
201223
// eslint-disable-next-line no-throw-literal -- testing non-Error handling
202224
throw 'plain string error';
203225
});
204226

205-
render(<InterlinearizerWebView {...testWebViewProps} />);
227+
await renderWebView();
206228

207229
expect(screen.getByRole('heading', { name: /^parse error$/i })).toBeInTheDocument();
208230
expect(screen.getByText('plain string error')).toBeInTheDocument();
209231
});
232+
233+
it('sets interlinearization to undefined when converter rejects', async () => {
234+
mockConvert.mockRejectedValueOnce(new Error('Conversion failed'));
235+
236+
const { container } = await renderWebView();
237+
fireEvent.click(screen.getByRole('radio', { name: /^interlinearization$/i }));
238+
await waitFor(() => {
239+
expect(container.querySelector('pre')).toBeInTheDocument();
240+
});
241+
242+
const jsonPre = container.querySelector('pre');
243+
expect(jsonPre).toBeInTheDocument();
244+
expect(jsonPre).toBeEmptyDOMElement();
245+
});
210246
});

0 commit comments

Comments
 (0)