Skip to content

Commit a769aa0

Browse files
Allow users to interlinearize "verse 0" of books (#123)
* Allow users to interlinearize "verse 0" of books * Improve closeCurrentVerse, remove unnecessary setting * Sync verse-0 (superscription) selection to the global scroll-group ref * Add clarifying comment
1 parent 39fa413 commit a769aa0

13 files changed

Lines changed: 526 additions & 106 deletions

src/__tests__/components/InterlinearNavContext.test.tsx

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,13 @@ describe('InterlinearNavContext', () => {
7474
expect(result.current.liveScrRef).toEqual(ref);
7575
});
7676

77-
it('normalizes a chapter-level (verse 0) reference to verse 1 in liveScrRef', () => {
77+
it('passes a chapter-level (verse 0) reference through to liveScrRef unchanged', () => {
78+
// Verse 0 is a real verse (a Psalm superscription), so it is no longer mapped to verse 1 here.
79+
// The loader resolves it to verse 1 only when the loaded book has no verse-0 segment.
7880
const ref: SerializedVerseRef = { book: 'GEN', chapterNum: 3, verseNum: 0 };
7981
const { result } = renderNav(makeScrollGroupHook(ref));
8082

81-
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 1 });
82-
// The raw reference still reports verse 0 so the editable nav controls reflect the selection.
83+
expect(result.current.liveScrRef).toEqual(ref);
8384
expect(result.current.rawScrRef).toEqual(ref);
8485
});
8586

@@ -100,9 +101,29 @@ describe('InterlinearNavContext', () => {
100101
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 7 });
101102
});
102103

103-
it('normalizes a verse-0 reference that names a different chapter (a real chapter jump)', () => {
104+
it('passes a same-chapter verse-0 echo through when it matches a fresh internal-nav marker', () => {
105+
// Selecting a verse-0 superscription navigates the host to verse 0 of the chapter already shown,
106+
// so the host's echo is shaped exactly like the spurious post-nav chapter echo. A fresh
107+
// internal-nav marker for that verse-0 key marks it as our own deliberate move, so the stickiness
108+
// exception lets it through to the superscription rather than holding the prior verse.
109+
const { result, setRef, rerender } = renderNavMutable({
110+
book: 'GEN',
111+
chapterNum: 3,
112+
verseNum: 7,
113+
});
114+
115+
act(() => result.current.navigate({ book: 'GEN', chapterNum: 3, verseNum: 0 }, 'internal'));
116+
117+
act(() => setRef({ book: 'GEN', chapterNum: 3, verseNum: 0 }));
118+
rerender();
119+
120+
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 0 });
121+
});
122+
123+
it('passes a verse-0 reference for a different chapter through as a real chapter jump', () => {
104124
// A verse-0 reference for a chapter other than the one shown is a genuine chapter navigation, not
105-
// an echo, so it still normalizes to that chapter's first verse.
125+
// an echo, so it is honored as verse 0 (the loader maps it to verse 1 if that chapter has no
126+
// verse-0 segment).
106127
const { result, setRef, rerender } = renderNavMutable({
107128
book: 'GEN',
108129
chapterNum: 3,
@@ -112,7 +133,7 @@ describe('InterlinearNavContext', () => {
112133
act(() => setRef({ book: 'GEN', chapterNum: 4, verseNum: 0 }));
113134
rerender();
114135

115-
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 4, verseNum: 1 });
136+
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 4, verseNum: 0 });
116137
});
117138

118139
describe('duplicate host deliveries', () => {
@@ -136,10 +157,10 @@ describe('InterlinearNavContext', () => {
136157
expect(result.current.liveScrRef).toBe(liveBefore);
137158
});
138159

139-
it('keeps liveScrRef identity when a verse-0 chapter jump is followed by its verse-1 form', () => {
140-
// A chapter jump can arrive as a verse-0 reference (normalized to verse 1) followed by the
141-
// explicit verse-1 reference. The raw references differ, but both normalize to the same
142-
// verse, so the committed liveScrRef object must be reused for the second delivery.
160+
it('treats a verse-0 chapter jump and its verse-1 form as two distinct deliveries', () => {
161+
// A chapter jump can arrive as a verse-0 reference followed by an explicit verse-1 reference.
162+
// Verse 0 and verse 1 are now distinct verses (verse 0 is the superscription), so the second
163+
// delivery is a genuine move to verse 1 rather than a deduped duplicate.
143164
const { result, setRef, rerender } = renderNavMutable({
144165
book: 'GEN',
145166
chapterNum: 3,
@@ -149,12 +170,12 @@ describe('InterlinearNavContext', () => {
149170
act(() => setRef({ book: 'GEN', chapterNum: 4, verseNum: 0 }));
150171
rerender();
151172
const liveAfterJump = result.current.liveScrRef;
152-
expect(liveAfterJump).toEqual({ book: 'GEN', chapterNum: 4, verseNum: 1 });
173+
expect(liveAfterJump).toEqual({ book: 'GEN', chapterNum: 4, verseNum: 0 });
153174

154175
act(() => setRef({ book: 'GEN', chapterNum: 4, verseNum: 1 }));
155176
rerender();
156177

157-
expect(result.current.liveScrRef).toBe(liveAfterJump);
178+
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 4, verseNum: 1 });
158179
});
159180

160181
it('reuses the previous reference when a duplicate differs only in the verse segment string', () => {
@@ -271,16 +292,20 @@ describe('InterlinearNavContext', () => {
271292
}
272293
});
273294

274-
it('matches a verse-0 internal mark against the host-normalized verse-1 reference', () => {
275-
// An internal navigation stamped at chapter granularity (verse 0) must still be consumable
276-
// when the host echoes it back normalized to the chapter's first verse (verse 1) — the keys
277-
// are computed through the same normalization so they cannot diverge on the verse-0 boundary.
295+
it('keys a verse-0 internal mark to verse 0, distinct from verse 1', () => {
296+
// Verse 0 (a superscription) is its own verse, so a verse-0 internal navigation is consumable
297+
// only by a verse-0 reference — not by verse 1 of the same chapter.
278298
const { result } = renderNav(
279299
makeScrollGroupHook({ book: 'GEN', chapterNum: 1, verseNum: 1 }),
280300
);
281301

282302
act(() => result.current.navigate({ book: 'GEN', chapterNum: 3, verseNum: 0 }, 'internal'));
283303
expect(result.current.consumeInternalNav({ book: 'GEN', chapterNum: 3, verseNum: 1 })).toBe(
304+
false,
305+
);
306+
307+
act(() => result.current.navigate({ book: 'GEN', chapterNum: 3, verseNum: 0 }, 'internal'));
308+
expect(result.current.consumeInternalNav({ book: 'GEN', chapterNum: 3, verseNum: 0 })).toBe(
284309
true,
285310
);
286311
});

src/__tests__/components/Interlinearizer.test.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,47 @@ const GEN_TWO_CHAPTER_BOOK: Book = {
313313
),
314314
};
315315

316+
/** GEN book whose chapter 1 opens with a verse-0 superscription segment before verse 1. */
317+
const GEN_SUPERSCRIPTION_BOOK: Book = {
318+
id: 'GEN',
319+
bookRef: 'GEN',
320+
textVersion: 'v1',
321+
segments: [
322+
{
323+
id: 'GEN 1:0',
324+
startRef: { book: 'GEN', chapter: 1, verse: 0 },
325+
endRef: { book: 'GEN', chapter: 1, verse: 0 },
326+
baselineText: 'A song.',
327+
tokens: [
328+
{
329+
ref: 'GEN 1:0:0',
330+
surfaceText: 'A',
331+
writingSystem: 'en',
332+
type: 'word',
333+
charStart: 0,
334+
charEnd: 1,
335+
},
336+
],
337+
},
338+
{
339+
id: 'GEN 1:1',
340+
startRef: { book: 'GEN', chapter: 1, verse: 1 },
341+
endRef: { book: 'GEN', chapter: 1, verse: 1 },
342+
baselineText: 'In the beginning.',
343+
tokens: [
344+
{
345+
ref: 'GEN 1:1:0',
346+
surfaceText: 'In',
347+
writingSystem: 'en',
348+
type: 'word',
349+
charStart: 0,
350+
charEnd: 2,
351+
},
352+
],
353+
},
354+
],
355+
};
356+
316357
/**
317358
* Wraps an `<Interlinearizer>` element in an {@link InterlinearNavProvider} so the component's
318359
* `useInterlinearNav` call resolves. `Interlinearizer` now writes the reference through the
@@ -492,6 +533,70 @@ describe('Interlinearizer', () => {
492533
expect(mockNavigate).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 });
493534
});
494535

536+
it('writes a verse-0 reference to the host when a verse-0 token is selected', () => {
537+
// Selecting a superscription token navigates the host to verse 0 like any other verse; the
538+
// internal-nav marker keeps the host's chapter echo from bouncing the view (the stickiness
539+
// exception in InterlinearNavContext). Default scrRef is GEN 1:1, so this is a real verse change.
540+
const mockNavigate = jest.fn();
541+
renderInterlinearizer({ book: GEN_SUPERSCRIPTION_BOOK, navigate: mockNavigate });
542+
543+
act(() => {
544+
capturedSegmentViewPropsList[0].onSelect?.(
545+
{ book: 'GEN', chapter: 1, verse: 0 },
546+
'GEN 1:0:0',
547+
);
548+
});
549+
550+
expect(mockNavigate).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 0 });
551+
});
552+
553+
it('writes a verse-0 reference to the host when a verse-0 token is focused from the strip', () => {
554+
const mockNavigate = jest.fn();
555+
renderInterlinearizer({
556+
book: GEN_SUPERSCRIPTION_BOOK,
557+
continuousScroll: true,
558+
navigate: mockNavigate,
559+
});
560+
561+
if (!capturedContinuousViewProps)
562+
throw new Error('Expected ContinuousView to have been rendered');
563+
const { onFocusedTokenRefChange } = capturedContinuousViewProps;
564+
565+
act(() => {
566+
onFocusedTokenRefChange('GEN 1:0:0');
567+
});
568+
569+
expect(mockNavigate).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 0 });
570+
expect(capturedContinuousViewProps.focusedTokenRef).toBe('GEN 1:0:0');
571+
});
572+
573+
it('moves the active-segment highlight to a verse-0 segment when its token is focused', () => {
574+
renderInterlinearizer({
575+
book: GEN_SUPERSCRIPTION_BOOK,
576+
scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 },
577+
});
578+
579+
// Active verse (1) is highlighted; the verse-0 superscription is not, yet.
580+
const before = Object.fromEntries(
581+
capturedSegmentViewPropsList.map((p) => [p.segment.id, p.isActive]),
582+
);
583+
expect(before['GEN 1:0']).toBeFalsy();
584+
expect(before['GEN 1:1']).toBe(true);
585+
586+
const { onSelect } = capturedSegmentViewPropsList[0];
587+
capturedSegmentViewPropsList = [];
588+
act(() => {
589+
onSelect({ book: 'GEN', chapter: 1, verse: 0 }, 'GEN 1:0:0');
590+
});
591+
592+
// Focusing the superscription's token moves the active highlight onto its segment.
593+
const after = Object.fromEntries(
594+
capturedSegmentViewPropsList.map((p) => [p.segment.id, p.isActive]),
595+
);
596+
expect(after['GEN 1:0']).toBe(true);
597+
expect(after['GEN 1:1']).toBeFalsy();
598+
});
599+
495600
it('passes the clicked token through to ContinuousView as focusedTokenRef', () => {
496601
jest.useFakeTimers();
497602
try {

src/__tests__/components/InterlinearizerLoader.test.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,9 @@ describe('InterlinearizerLoader', () => {
469469
expect(screen.getByTestId('interlinearizer')).toBeInTheDocument();
470470
});
471471

472-
it('normalizes a chapter-level (verse 0) reference to verse 1 before passing it to Interlinearizer', async () => {
472+
it('resolves a verse-0 reference to verse 1 when the book has no verse-0 segment', async () => {
473+
// GEN_1_1_BOOK has only a GEN 1:1 segment, so a whole-chapter (verse 0) selection falls back to
474+
// the chapter's first numbered verse rather than leaving nothing highlighted.
473475
await act(async () => {
474476
renderLoader({
475477
useWebViewScrollGroupScrRef: makeScrollGroupHook({
@@ -487,6 +489,59 @@ describe('InterlinearizerLoader', () => {
487489
});
488490
});
489491

492+
it('keeps a verse-0 reference when the book has a verse-0 (superscription) segment', async () => {
493+
const bookWithSuperscription: Book = {
494+
id: 'PSA',
495+
bookRef: 'PSA',
496+
textVersion: 'v1',
497+
segments: [
498+
{
499+
id: 'PSA 3:0',
500+
startRef: { book: 'PSA', chapter: 3, verse: 0 },
501+
endRef: { book: 'PSA', chapter: 3, verse: 0 },
502+
baselineText: 'A Psalm by David.',
503+
tokens: [],
504+
},
505+
],
506+
};
507+
mockBookData({ book: bookWithSuperscription });
508+
509+
await act(async () => {
510+
renderLoader({
511+
useWebViewScrollGroupScrRef: makeScrollGroupHook({
512+
book: 'PSA',
513+
chapterNum: 3,
514+
verseNum: 0,
515+
}),
516+
});
517+
});
518+
519+
expect(capturedInterlinearizerProps?.scrRef).toEqual({
520+
book: 'PSA',
521+
chapterNum: 3,
522+
verseNum: 0,
523+
});
524+
});
525+
526+
it('leaves a verse-0 reference untouched while the book is still loading', async () => {
527+
// With no book loaded yet, the verse-0 resolution has nothing to consult, so the loader shows
528+
// the loading placeholder and does not render the interlinearizer.
529+
mockBookData({ book: undefined, isLoading: true });
530+
531+
await act(async () => {
532+
renderLoader({
533+
useWebViewScrollGroupScrRef: makeScrollGroupHook({
534+
book: 'GEN',
535+
chapterNum: 3,
536+
verseNum: 0,
537+
}),
538+
});
539+
});
540+
541+
expect(screen.getByText('Loading…')).toBeInTheDocument();
542+
expect(screen.queryByTestId('interlinearizer')).not.toBeInTheDocument();
543+
});
544+
490545
it('passes a verse-level reference through to Interlinearizer unchanged', async () => {
491546
await act(async () => {
492547
renderLoader({

src/__tests__/components/SegmentView.test.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ describe('SegmentView', () => {
320320
expect(container.firstChild).toHaveAttribute('aria-current', 'true');
321321
});
322322

323-
it('calls onSelect when clicked in baseline-text mode', async () => {
323+
it('calls onSelect with the first word token when clicked in baseline-text mode', async () => {
324324
const handleSelect = jest.fn();
325325
render(
326326
<SegmentView {...requiredProps()} displayMode="baseline-text" onSelect={handleSelect} />,
@@ -329,8 +329,10 @@ describe('SegmentView', () => {
329329

330330
await userEvent.click(screen.getByTestId('segment-container'));
331331

332+
// Passes the first word token so the segment gains focus (and the active highlight) on click,
333+
// letting the parent both highlight the segment and navigate to its verse.
332334
expect(handleSelect).toHaveBeenCalledTimes(1);
333-
expect(handleSelect).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 1 });
335+
expect(handleSelect).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 1 }, 'tok-0');
334336
});
335337

336338
it('renders a free-translation input below the plain text in baseline-text mode', () => {

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,27 @@ describe('tokenizeBook', () => {
5050
expect(segments[0].endRef).toEqual({ book: 'GEN', chapter: 1, verse: 1 });
5151
});
5252

53+
it('builds a verse-0 segment from a verse-0 SID (Psalm superscription)', () => {
54+
const raw: RawBook = {
55+
bookCode: 'PSA',
56+
writingSystem: 'en',
57+
contentHash: 'abc123',
58+
verses: [{ sid: 'PSA 3:0', text: 'A Psalm by David.' }],
59+
};
60+
const { segments } = tokenizeBook(raw);
61+
expect(segments).toHaveLength(1);
62+
expect(segments[0].id).toBe('PSA 3:0');
63+
expect(segments[0].startRef).toEqual({ book: 'PSA', chapter: 3, verse: 0 });
64+
expect(segments[0].endRef).toEqual({ book: 'PSA', chapter: 3, verse: 0 });
65+
expect(segments[0].tokens.map((t) => t.surfaceText)).toEqual([
66+
'A',
67+
'Psalm',
68+
'by',
69+
'David',
70+
'.',
71+
]);
72+
});
73+
5374
it('upholds the charStart/charEnd invariant for every token', () => {
5475
const text = 'In the beginning, God created.';
5576
const { segments } = tokenizeBook(makeRawBook([{ sid: 'GEN 1:1', text }]));

0 commit comments

Comments
 (0)