Skip to content

Commit d56c6af

Browse files
Improve PT9 XML Parser error handling, reflow some tests, improve docs
1 parent 3778de2 commit d56c6af

8 files changed

Lines changed: 181 additions & 23 deletions

File tree

src/__tests__/components/ContinuousView.test.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,12 @@ function makeBook(overrides?: Partial<Book>): Book {
101101
};
102102
}
103103

104-
/** A two-chapter book: chapter 1 has one segment, chapter 2 has one segment. */
104+
/**
105+
* Builds a two-chapter Book fixture: chapter 1 has one segment ("Alpha"), chapter 2 has one segment
106+
* ("Beta"). Used to exercise cross-chapter traversal and verse-jump behaviour.
107+
*
108+
* @returns A two-chapter Book.
109+
*/
105110
function makeTwoChapterBook(): Book {
106111
return {
107112
id: 'GEN',
@@ -144,7 +149,12 @@ function makeTwoChapterBook(): Book {
144149
};
145150
}
146151

147-
/** A book with exactly one token (minimal edge case). */
152+
/**
153+
* Builds a Book with exactly one word token in one segment. Used to assert that both navigation
154+
* arrows are disabled when the strip has nowhere to move.
155+
*
156+
* @returns A single-token Book.
157+
*/
148158
function makeSingleTokenBook(): Book {
149159
return {
150160
id: 'GEN',
@@ -218,7 +228,12 @@ function makeMixedBook(): Book {
218228
};
219229
}
220230

221-
/** A book where every token is non-word, so phraseEntries is empty. */
231+
/**
232+
* Builds a Book whose only token is punctuation, so phraseEntries is empty. Used to exercise the
233+
* code path where ContinuousView renders with no word tokens to navigate between.
234+
*
235+
* @returns A word-free Book.
236+
*/
222237
function makeWordFreeBook(): Book {
223238
return {
224239
id: 'GEN',

src/__tests__/components/Interlinearizer.test.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ const GEN_1_MULTI_BOOK: Book = {
109109
],
110110
};
111111

112+
/**
113+
* Renders an Interlinearizer component with sensible defaults, allowing individual props to be
114+
* overridden per test.
115+
*
116+
* @param options - Partial props to merge over the defaults.
117+
* @returns The render result from @testing-library/react.
118+
*/
112119
function renderInterlinearizer({
113120
book = GEN_1_1_BOOK,
114121
bookSegments = GEN_1_1_BOOK.segments,
@@ -226,9 +233,9 @@ describe('Interlinearizer', () => {
226233

227234
expect(screen.getByTestId('continuous-view')).toBeInTheDocument();
228235

229-
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-type-assertion/no-type-assertion
230-
const onVerseChange = capturedContinuousViewProps.onVerseChange as any;
231-
expect(onVerseChange).toBeDefined();
236+
const { onVerseChange } = capturedContinuousViewProps;
237+
if (typeof onVerseChange !== 'function')
238+
throw new Error('Expected onVerseChange to be a function');
232239

233240
onVerseChange({ book: 'GEN', chapter: 2, verse: 3 });
234241

src/__tests__/hooks/useInterlinearizerBookData.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,20 @@ const TEST_BOOK: Book = {
8787
const GEN_1_1_SRC_REF = { book: 'GEN', chapterNum: 1, verseNum: 1 };
8888

8989
describe('useInterlinearizerBookData', () => {
90-
/** Default mock setup for useProjectData */
90+
/**
91+
* Configures useProjectData to return a resolved USJ object so the hook can proceed to
92+
* extractBookFromUsj and tokenizeBook without hitting the error-state branches.
93+
*/
9194
const setupDefaultProjectDataMock = () => {
9295
jest.mocked(useProjectData).mockReturnValue({
9396
BookUSJ: () => [{ USJ: 'mock-usj' }, jest.fn(), false],
9497
});
9598
};
9699

97-
/** Default mock setup for useProjectSetting */
100+
/**
101+
* Configures useProjectSetting to return the writing system code 'en' so the hook uses a
102+
* valid BCP 47 tag rather than falling back to 'und'.
103+
*/
98104
const setupDefaultProjectSettingMock = () => {
99105
jest.mocked(useProjectSetting).mockReturnValue(['en', jest.fn(), jest.fn(), false]);
100106
};

src/__tests__/main.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,22 @@ const {
5050
__mockLogger,
5151
} = papiBackendMock;
5252

53+
/**
54+
* Type guard that narrows an unknown value to a callable function.
55+
*
56+
* @param f - The value to test.
57+
* @returns True if f is a function.
58+
*/
5359
function isCallable(f: unknown): f is (...args: unknown[]) => unknown {
5460
return typeof f === 'function';
5561
}
5662

63+
/**
64+
* Finds the handler registered for a command name in the mocked registerCommand calls.
65+
*
66+
* @param commandName - The command name to look up.
67+
* @returns The handler function, or undefined if no matching call was recorded.
68+
*/
5769
function findRegisteredHandler(commandName: string): ((...args: unknown[]) => unknown) | undefined {
5870
const call = jest.mocked(__mockRegisterCommand).mock.calls.find((c) => c[0] === commandName);
5971
const rawHandler: unknown = call?.[1];
@@ -73,12 +85,24 @@ async function getOpenForWebViewHandler(): Promise<
7385
};
7486
}
7587

88+
/**
89+
* Retrieves the callback passed to onDidOpenWebView during the most recent activate() call.
90+
*
91+
* @returns A typed wrapper around the captured callback.
92+
* @throws If no callback was registered (i.e. activate was not called first).
93+
*/
7694
function getOpenWebViewCallback(): (event: { webView: SavedWebViewDefinition }) => void {
7795
const cb: unknown = __mockOnDidOpenWebView.mock.calls[0]?.[0];
7896
if (!isCallable(cb)) throw new Error('onDidOpenWebView callback not found');
7997
return (event) => cb(event);
8098
}
8199

100+
/**
101+
* Retrieves the callback passed to onDidCloseWebView during the most recent activate() call.
102+
*
103+
* @returns A typed wrapper around the captured callback.
104+
* @throws If no callback was registered (i.e. activate was not called first).
105+
*/
82106
function getCloseWebViewCallback(): (event: { webView: SavedWebViewDefinition }) => void {
83107
const cb: unknown = __mockOnDidCloseWebView.mock.calls[0]?.[0];
84108
if (!isCallable(cb)) throw new Error('onDidCloseWebView callback not found');

src/__tests__/parsers/papi/bookTokenizer.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
import { tokenizeBook } from 'parsers/papi/bookTokenizer';
55
import type { RawBook } from 'parsers/papi/usjBookExtractor';
66

7+
/**
8+
* Builds a minimal RawBook fixture for GEN with the given verses.
9+
*
10+
* @param verses - Array of verse objects (sid + text) to include in the book.
11+
* @returns A RawBook with fixed bookCode, writingSystem, and contentHash.
12+
*/
713
function makeRawBook(verses: { sid: string; text: string }[]): RawBook {
814
return { bookCode: 'GEN', writingSystem: 'en', contentHash: 'abc123', verses };
915
}

src/__tests__/parsers/papi/usjBookExtractor.test.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ describe('extractBookFromUsj', () => {
2020
expect(extractBookFromUsj(usj, 'kmr').writingSystem).toBe('kmr');
2121
});
2222

23-
it('produces a stable contentHash independent of writingSystem', () => {
23+
it('produces the same contentHash for identical content with different writingSystems', () => {
2424
const a: UsjDocument = { content: [{ type: 'book', code: 'GEN', content: [] }] };
2525
const b: UsjDocument = { content: [...a.content] };
26-
// Same content, different writingSystem — hash must be identical (it hashes content only).
2726
expect(extractBookFromUsj(a, 'en').contentHash).toBe(extractBookFromUsj(b, 'es').contentHash);
28-
// Different content must yield a different hash.
27+
});
28+
29+
it('produces a different contentHash for different content', () => {
30+
const a: UsjDocument = { content: [{ type: 'book', code: 'GEN', content: [] }] };
2931
const c: UsjDocument = { content: [{ type: 'book', code: 'MAT', content: [] }] };
3032
expect(extractBookFromUsj(a, WS).contentHash).not.toBe(extractBookFromUsj(c, WS).contentHash);
3133
});
@@ -263,6 +265,63 @@ describe('extractBookFromUsj', () => {
263265
expect(verses[0].text).toBe('I have gone astray');
264266
});
265267

268+
it('skips text inside a heading para marker that appears mid-verse (before the verse is closed)', () => {
269+
// An s1 heading node that arrives while a verse is still open must not contribute its text.
270+
const usj: UsjDocument = {
271+
content: [
272+
{ type: 'book', code: 'PSA', content: [] },
273+
{
274+
type: 'para',
275+
marker: 'p',
276+
content: [{ type: 'verse', sid: 'PSA 1:1' }, 'Blessed is the man'],
277+
},
278+
// s1 heading arrives while PSA 1:1 is still the currentVerse
279+
{ type: 'para', marker: 's1', content: ['Interlude'] },
280+
{
281+
type: 'para',
282+
marker: 'p',
283+
content: ['who walks not in the counsel of the wicked.'],
284+
},
285+
],
286+
};
287+
const { verses } = extractBookFromUsj(usj, WS);
288+
expect(verses).toHaveLength(1);
289+
expect(verses[0].text).toBe('Blessed is the man who walks not in the counsel of the wicked.');
290+
});
291+
292+
it('includes text nested inside multiple levels of inline char nodes', () => {
293+
// The traverse fallback recurses into any unknown node that has content, so deeply
294+
// nested char nodes must still contribute their text.
295+
const usj: UsjDocument = {
296+
content: [
297+
{ type: 'book', code: 'JHN', content: [] },
298+
{
299+
type: 'para',
300+
marker: 'p',
301+
content: [
302+
{ type: 'verse', sid: 'JHN 1:14' },
303+
'And the ',
304+
{
305+
type: 'char',
306+
marker: 'em',
307+
content: [
308+
{
309+
type: 'char',
310+
marker: 'nd',
311+
content: ['Word'],
312+
},
313+
],
314+
},
315+
' became flesh.',
316+
],
317+
},
318+
],
319+
};
320+
const { verses } = extractBookFromUsj(usj, WS);
321+
expect(verses).toHaveLength(1);
322+
expect(verses[0].text).toBe('And the Word became flesh.');
323+
});
324+
266325
it('produces a stable contentHash when a node has an optional property explicitly set to undefined', () => {
267326
const withUndefined: UsjDocument = {
268327
content: [{ type: 'book', code: 'GEN', marker: undefined, content: [] }],

src/__tests__/parsers/pt9/interlinearXmlParser.test.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,47 @@ describe('InterlinearXmlParser', () => {
466466
});
467467
});
468468

469+
it('parses multiple Punctuation entries in a single verse', () => {
470+
const xml = `
471+
<InterlinearData GlossLanguage="en" BookId="MAT">
472+
<Verses>
473+
<item>
474+
<string>MAT 1:1</string>
475+
<VerseData>
476+
<Cluster>
477+
<Range Index="0" Length="1" />
478+
<Lexeme Id="x" />
479+
</Cluster>
480+
<Punctuation>
481+
<Range Index="5" Length="1" />
482+
<BeforeText>,</BeforeText>
483+
<AfterText> </AfterText>
484+
</Punctuation>
485+
<Punctuation>
486+
<Range Index="12" Length="1" />
487+
<BeforeText>.</BeforeText>
488+
<AfterText></AfterText>
489+
</Punctuation>
490+
</VerseData>
491+
</item>
492+
</Verses>
493+
</InterlinearData>
494+
`;
495+
const result = parser.parse(xml);
496+
497+
expect(result.Verses['MAT 1:1'].Punctuations).toHaveLength(2);
498+
expect(result.Verses['MAT 1:1'].Punctuations[0]).toEqual({
499+
TextRange: { Index: 5, Length: 1 },
500+
BeforeText: ',',
501+
AfterText: ' ',
502+
});
503+
expect(result.Verses['MAT 1:1'].Punctuations[1]).toEqual({
504+
TextRange: { Index: 12, Length: 1 },
505+
BeforeText: '.',
506+
AfterText: '',
507+
});
508+
});
509+
469510
it('parses VerseData with Punctuation but no Cluster (Cluster ?? [] branch)', () => {
470511
const xml = `
471512
<InterlinearData GlossLanguage="en" BookId="MAT">
@@ -718,9 +759,7 @@ describe('InterlinearXmlParser', () => {
718759
expect(() => parser.parse(xmlNoIndex)).toThrow(
719760
expect.objectContaining({
720761
name: 'SyntaxError',
721-
message: expect.stringContaining(
722-
'Invalid XML: Range missing or invalid Index/Length attributes (must be non-negative integers)',
723-
),
762+
message: expect.stringContaining('Invalid XML: Range missing Index or Length attribute'),
724763
}),
725764
);
726765

@@ -742,9 +781,7 @@ describe('InterlinearXmlParser', () => {
742781
expect(() => parser.parse(xmlNoLength)).toThrow(
743782
expect.objectContaining({
744783
name: 'SyntaxError',
745-
message: expect.stringContaining(
746-
'Invalid XML: Range missing or invalid Index/Length attributes (must be non-negative integers)',
747-
),
784+
message: expect.stringContaining('Invalid XML: Range missing Index or Length attribute'),
748785
}),
749786
);
750787
});
@@ -846,7 +883,7 @@ describe('InterlinearXmlParser', () => {
846883
expect.objectContaining({
847884
name: 'SyntaxError',
848885
message: expect.stringContaining(
849-
'Invalid XML: Range missing or invalid Index/Length attributes (must be non-negative integers)',
886+
'Invalid XML: Range has invalid Index/Length attributes (must be non-negative integers)',
850887
),
851888
}),
852889
);

src/parsers/pt9/interlinearXmlParser.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,8 @@ function extractPunctuationsFromVerse(verseDataElement: ParsedVerseData): Punctu
211211
* @param verseDataElement - Parsed VerseData from fast-xml-parser (may have Cluster array or none).
212212
* @returns Array of ClusterData: TextRange from Cluster's Range, Lexemes from Lexeme children,
213213
* LexemesId (slash-joined), Id (LexemesId/Index-Length or Index-Length when no lexemes).
214-
* @throws {SyntaxError} If a Cluster is missing its Range element or Range is missing Index or
215-
* Length.
214+
* @throws {SyntaxError} If a Cluster is missing its Range element, Range is missing Index or
215+
* Length, or Range Index/Length are not non-negative integers.
216216
*/
217217
function extractClustersFromVerse(verseDataElement: ParsedVerseData): ClusterData[] {
218218
const clusterElements = verseDataElement.Cluster ?? [];
@@ -223,11 +223,15 @@ function extractClustersFromVerse(verseDataElement: ParsedVerseData): ClusterDat
223223
throw new SyntaxError('Invalid XML: Cluster missing required Range element');
224224
}
225225

226+
if (rangeElement['@_Index'] === undefined || rangeElement['@_Length'] === undefined) {
227+
throw new SyntaxError('Invalid XML: Range missing Index or Length attribute');
228+
}
229+
226230
const index = parseStrictNumber(rangeElement['@_Index']);
227231
const length = parseStrictNumber(rangeElement['@_Length']);
228232
if (index === undefined || length === undefined) {
229233
throw new SyntaxError(
230-
'Invalid XML: Range missing or invalid Index/Length attributes (must be non-negative integers)',
234+
'Invalid XML: Range has invalid Index/Length attributes (must be non-negative integers)',
231235
);
232236
}
233237

@@ -299,8 +303,8 @@ export class InterlinearXmlParser {
299303
* @throws {SyntaxError} If the `InterlinearData` root element is absent.
300304
* @throws {SyntaxError} If `GlossLanguage` or `BookId` attributes are missing or empty.
301305
* @throws {SyntaxError} If the `Verses` element is absent.
302-
* @throws {SyntaxError} If a `Cluster` is missing its `Range` element, or `Range` has invalid
303-
* `Index`/`Length` attributes (must be non-negative integers).
306+
* @throws {SyntaxError} If a `Cluster` is missing its `Range` element, `Range` is missing
307+
* `Index`/`Length`, or `Index`/`Length` are not non-negative integers.
304308
* @throws {SyntaxError} If a `Lexeme` element is missing its required `Id` attribute.
305309
* @throws {SyntaxError} If the same verse reference appears in more than one `item`.
306310
*/

0 commit comments

Comments
 (0)