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
57 changes: 41 additions & 16 deletions src/__tests__/components/InterlinearNavContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,13 @@ describe('InterlinearNavContext', () => {
expect(result.current.liveScrRef).toEqual(ref);
});

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

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

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

it('normalizes a verse-0 reference that names a different chapter (a real chapter jump)', () => {
it('passes a same-chapter verse-0 echo through when it matches a fresh internal-nav marker', () => {
// Selecting a verse-0 superscription navigates the host to verse 0 of the chapter already shown,
// so the host's echo is shaped exactly like the spurious post-nav chapter echo. A fresh
// internal-nav marker for that verse-0 key marks it as our own deliberate move, so the stickiness
// exception lets it through to the superscription rather than holding the prior verse.
const { result, setRef, rerender } = renderNavMutable({
book: 'GEN',
chapterNum: 3,
verseNum: 7,
});

act(() => result.current.navigate({ book: 'GEN', chapterNum: 3, verseNum: 0 }, 'internal'));

act(() => setRef({ book: 'GEN', chapterNum: 3, verseNum: 0 }));
rerender();

expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 0 });
});

it('passes a verse-0 reference for a different chapter through as a real chapter jump', () => {
// A verse-0 reference for a chapter other than the one shown is a genuine chapter navigation, not
// an echo, so it still normalizes to that chapter's first verse.
// an echo, so it is honored as verse 0 (the loader maps it to verse 1 if that chapter has no
// verse-0 segment).
const { result, setRef, rerender } = renderNavMutable({
book: 'GEN',
chapterNum: 3,
Expand All @@ -112,7 +133,7 @@ describe('InterlinearNavContext', () => {
act(() => setRef({ book: 'GEN', chapterNum: 4, verseNum: 0 }));
rerender();

expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 4, verseNum: 1 });
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 4, verseNum: 0 });
});

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

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

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

expect(result.current.liveScrRef).toBe(liveAfterJump);
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 4, verseNum: 1 });
});

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

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

act(() => result.current.navigate({ book: 'GEN', chapterNum: 3, verseNum: 0 }, 'internal'));
expect(result.current.consumeInternalNav({ book: 'GEN', chapterNum: 3, verseNum: 1 })).toBe(
false,
);

act(() => result.current.navigate({ book: 'GEN', chapterNum: 3, verseNum: 0 }, 'internal'));
expect(result.current.consumeInternalNav({ book: 'GEN', chapterNum: 3, verseNum: 0 })).toBe(
true,
);
});
Expand Down
105 changes: 105 additions & 0 deletions src/__tests__/components/Interlinearizer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,47 @@ const GEN_TWO_CHAPTER_BOOK: Book = {
),
};

/** GEN book whose chapter 1 opens with a verse-0 superscription segment before verse 1. */
const GEN_SUPERSCRIPTION_BOOK: Book = {
id: 'GEN',
bookRef: 'GEN',
textVersion: 'v1',
segments: [
{
id: 'GEN 1:0',
startRef: { book: 'GEN', chapter: 1, verse: 0 },
endRef: { book: 'GEN', chapter: 1, verse: 0 },
baselineText: 'A song.',
tokens: [
{
ref: 'GEN 1:0:0',
surfaceText: 'A',
writingSystem: 'en',
type: 'word',
charStart: 0,
charEnd: 1,
},
],
},
{
id: 'GEN 1:1',
startRef: { book: 'GEN', chapter: 1, verse: 1 },
endRef: { book: 'GEN', chapter: 1, verse: 1 },
baselineText: 'In the beginning.',
tokens: [
{
ref: 'GEN 1:1:0',
surfaceText: 'In',
writingSystem: 'en',
type: 'word',
charStart: 0,
charEnd: 2,
},
],
},
],
};

/**
* Wraps an `<Interlinearizer>` element in an {@link InterlinearNavProvider} so the component's
* `useInterlinearNav` call resolves. `Interlinearizer` now writes the reference through the
Expand Down Expand Up @@ -492,6 +533,70 @@ describe('Interlinearizer', () => {
expect(mockNavigate).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 });
});

it('writes a verse-0 reference to the host when a verse-0 token is selected', () => {
// Selecting a superscription token navigates the host to verse 0 like any other verse; the
// internal-nav marker keeps the host's chapter echo from bouncing the view (the stickiness
// exception in InterlinearNavContext). Default scrRef is GEN 1:1, so this is a real verse change.
const mockNavigate = jest.fn();
renderInterlinearizer({ book: GEN_SUPERSCRIPTION_BOOK, navigate: mockNavigate });

act(() => {
capturedSegmentViewPropsList[0].onSelect?.(
{ book: 'GEN', chapter: 1, verse: 0 },
'GEN 1:0:0',
);
});

expect(mockNavigate).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 0 });
});

it('writes a verse-0 reference to the host when a verse-0 token is focused from the strip', () => {
const mockNavigate = jest.fn();
renderInterlinearizer({
book: GEN_SUPERSCRIPTION_BOOK,
continuousScroll: true,
navigate: mockNavigate,
});

if (!capturedContinuousViewProps)
throw new Error('Expected ContinuousView to have been rendered');
const { onFocusedTokenRefChange } = capturedContinuousViewProps;

act(() => {
onFocusedTokenRefChange('GEN 1:0:0');
});

expect(mockNavigate).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 0 });
expect(capturedContinuousViewProps.focusedTokenRef).toBe('GEN 1:0:0');
});

it('moves the active-segment highlight to a verse-0 segment when its token is focused', () => {
renderInterlinearizer({
book: GEN_SUPERSCRIPTION_BOOK,
scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 },
});

// Active verse (1) is highlighted; the verse-0 superscription is not, yet.
const before = Object.fromEntries(
capturedSegmentViewPropsList.map((p) => [p.segment.id, p.isActive]),
);
expect(before['GEN 1:0']).toBeFalsy();
expect(before['GEN 1:1']).toBe(true);

const { onSelect } = capturedSegmentViewPropsList[0];
capturedSegmentViewPropsList = [];
act(() => {
onSelect({ book: 'GEN', chapter: 1, verse: 0 }, 'GEN 1:0:0');
});

// Focusing the superscription's token moves the active highlight onto its segment.
const after = Object.fromEntries(
capturedSegmentViewPropsList.map((p) => [p.segment.id, p.isActive]),
);
expect(after['GEN 1:0']).toBe(true);
expect(after['GEN 1:1']).toBeFalsy();
});

it('passes the clicked token through to ContinuousView as focusedTokenRef', () => {
jest.useFakeTimers();
try {
Expand Down
57 changes: 56 additions & 1 deletion src/__tests__/components/InterlinearizerLoader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,9 @@ describe('InterlinearizerLoader', () => {
expect(screen.getByTestId('interlinearizer')).toBeInTheDocument();
});

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

it('keeps a verse-0 reference when the book has a verse-0 (superscription) segment', async () => {
const bookWithSuperscription: Book = {
id: 'PSA',
bookRef: 'PSA',
textVersion: 'v1',
segments: [
{
id: 'PSA 3:0',
startRef: { book: 'PSA', chapter: 3, verse: 0 },
endRef: { book: 'PSA', chapter: 3, verse: 0 },
baselineText: 'A Psalm by David.',
tokens: [],
},
],
};
mockBookData({ book: bookWithSuperscription });

await act(async () => {
renderLoader({
useWebViewScrollGroupScrRef: makeScrollGroupHook({
book: 'PSA',
chapterNum: 3,
verseNum: 0,
}),
});
});

expect(capturedInterlinearizerProps?.scrRef).toEqual({
book: 'PSA',
chapterNum: 3,
verseNum: 0,
});
});

it('leaves a verse-0 reference untouched while the book is still loading', async () => {
// With no book loaded yet, the verse-0 resolution has nothing to consult, so the loader shows
// the loading placeholder and does not render the interlinearizer.
mockBookData({ book: undefined, isLoading: true });

await act(async () => {
renderLoader({
useWebViewScrollGroupScrRef: makeScrollGroupHook({
book: 'GEN',
chapterNum: 3,
verseNum: 0,
}),
});
});

expect(screen.getByText('Loading…')).toBeInTheDocument();
expect(screen.queryByTestId('interlinearizer')).not.toBeInTheDocument();
});

it('passes a verse-level reference through to Interlinearizer unchanged', async () => {
await act(async () => {
renderLoader({
Expand Down
6 changes: 4 additions & 2 deletions src/__tests__/components/SegmentView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ describe('SegmentView', () => {
expect(container.firstChild).toHaveAttribute('aria-current', 'true');
});

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

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

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

it('renders a free-translation input below the plain text in baseline-text mode', () => {
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/parsers/papi/bookTokenizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@ describe('tokenizeBook', () => {
expect(segments[0].endRef).toEqual({ book: 'GEN', chapter: 1, verse: 1 });
});

it('builds a verse-0 segment from a verse-0 SID (Psalm superscription)', () => {
const raw: RawBook = {
bookCode: 'PSA',
writingSystem: 'en',
contentHash: 'abc123',
verses: [{ sid: 'PSA 3:0', text: 'A Psalm by David.' }],
};
const { segments } = tokenizeBook(raw);
expect(segments).toHaveLength(1);
expect(segments[0].id).toBe('PSA 3:0');
expect(segments[0].startRef).toEqual({ book: 'PSA', chapter: 3, verse: 0 });
expect(segments[0].endRef).toEqual({ book: 'PSA', chapter: 3, verse: 0 });
expect(segments[0].tokens.map((t) => t.surfaceText)).toEqual([
'A',
'Psalm',
'by',
'David',
'.',
]);
});

it('upholds the charStart/charEnd invariant for every token', () => {
const text = 'In the beginning, God created.';
const { segments } = tokenizeBook(makeRawBook([{ sid: 'GEN 1:1', text }]));
Expand Down
Loading
Loading