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
80 changes: 30 additions & 50 deletions src/__tests__/components/InterlinearNavContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function renderNav(hook: () => ScrollGroupTuple) {
/**
* Renders the nav hook with a scroll-group stub whose reference can be restaged between rerenders,
* so a cross-book navigation can be simulated. A fresh object identity is required on each change
* so the provider's `liveScrRef` memo recomputes.
* so the provider adopts it as a new `scrRef`.
*
* @param initial - The reference reported on the first render.
* @returns The render-hook result plus a `setRef` to stage the next reference (call inside `act`,
Expand All @@ -51,13 +51,13 @@ function renderNavMutable(initial: SerializedVerseRef) {
}

describe('InterlinearNavContext', () => {
it('exposes the raw reference and scroll-group plumbing verbatim', () => {
it('exposes the host reference and scroll-group plumbing verbatim', () => {
const setScrRef = jest.fn();
const setScrollGroupId = jest.fn();
const ref: SerializedVerseRef = { book: 'GEN', chapterNum: 3, verseNum: 4 };
const { result } = renderNav(makeScrollGroupHook(ref, setScrRef, 2, setScrollGroupId));

expect(result.current.rawScrRef).toEqual(ref);
expect(result.current.scrRef).toEqual(ref);
expect(result.current.scrollGroupId).toBe(2);

act(() => result.current.navigate({ book: 'MAT', chapterNum: 1, verseNum: 1 }));
Expand All @@ -67,57 +67,38 @@ describe('InterlinearNavContext', () => {
expect(setScrollGroupId).toHaveBeenCalledWith(3);
});

it('passes a verse-level reference through to liveScrRef unchanged', () => {
it('passes a verse-level reference through to scrRef unchanged', () => {
const ref: SerializedVerseRef = { book: 'GEN', chapterNum: 3, verseNum: 4 };
const { result } = renderNav(makeScrollGroupHook(ref));

expect(result.current.liveScrRef).toEqual(ref);
expect(result.current.scrRef).toEqual(ref);
});

it('passes a chapter-level (verse 0) reference through to liveScrRef unchanged', () => {
it('passes a chapter-level (verse 0) reference through to scrRef 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(ref);
expect(result.current.rawScrRef).toEqual(ref);
expect(result.current.scrRef).toEqual(ref);
});

it('keeps the current verse when the host echoes a verse-0 reference for the chapter already shown', () => {
// After a verse navigation the host re-broadcasts the chapter as a separate verse-0 reference.
// Normalizing that to verse 1 would read as a fresh move off the verse the user is on, so a
// verse-0 echo for the same book+chapter must stay sticky on the current verse.
it('passes a same-chapter verse-0 reference through to scrRef (host < from verse 1)', () => {
// Verse 0 is a real, focusable verse (a chapter superscription), so a verse-0 reference for the
// chapter already shown passes through verbatim rather than being held on the current verse. This
// is the host's `<` (previous-verse) from verse 1: it must land on the superscription, which the
// loader resolves from verse 0 (or back to verse 1 when the chapter has no verse-0 segment).
const { result, setRef, rerender } = renderNavMutable({
book: 'GEN',
chapterNum: 3,
verseNum: 7,
verseNum: 1,
});
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 7 });
expect(result.current.scrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 1 });

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

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

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 });
expect(result.current.scrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 0 });
});

it('passes a verse-0 reference for a different chapter through as a real chapter jump', () => {
Expand All @@ -133,28 +114,26 @@ describe('InterlinearNavContext', () => {
act(() => setRef({ book: 'GEN', chapterNum: 4, verseNum: 0 }));
rerender();

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

describe('duplicate host deliveries', () => {
it('keeps rawScrRef and liveScrRef identity when the host re-sends a value-equal reference', () => {
it('keeps scrRef identity when the host re-sends a value-equal reference', () => {
// The scripture picker fires each external navigation twice in quick succession, the second
// delivery being a fresh object with identical content. The provider must hand back the
// previously adopted objects so the duplicate is invisible to consumers (no context-value
// previously adopted object so the duplicate is invisible to consumers (no context-value
// change, no re-render churn mid-recenter).
const { result, setRef, rerender } = renderNavMutable({
book: 'GEN',
chapterNum: 3,
verseNum: 7,
});
const rawBefore = result.current.rawScrRef;
const liveBefore = result.current.liveScrRef;
const before = result.current.scrRef;

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

expect(result.current.rawScrRef).toBe(rawBefore);
expect(result.current.liveScrRef).toBe(liveBefore);
expect(result.current.scrRef).toBe(before);
});

it('treats a verse-0 chapter jump and its verse-1 form as two distinct deliveries', () => {
Expand All @@ -169,13 +148,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: 0 });
expect(result.current.scrRef).toEqual({ book: 'GEN', chapterNum: 4, verseNum: 0 });

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

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

it('reuses the previous reference when a duplicate differs only in the verse segment string', () => {
Expand All @@ -188,7 +166,7 @@ describe('InterlinearNavContext', () => {
act(() => setRef({ book: 'GEN', chapterNum: 3, verseNum: 7, verse: '7a' }));
rerender();

expect(result.current.rawScrRef).toBe(initial);
expect(result.current.scrRef).toBe(initial);
});

it('reuses the previous reference when a duplicate differs only in versification', () => {
Expand All @@ -207,7 +185,7 @@ describe('InterlinearNavContext', () => {
);
rerender();

expect(result.current.rawScrRef).toBe(initial);
expect(result.current.scrRef).toBe(initial);
});
});

Expand Down Expand Up @@ -542,9 +520,11 @@ describe('InterlinearNavContext', () => {
expect(result.current.fadePhase).toBe('out');
});

it('does not re-engage the curtain for a verse-0 echo during the fade-in', () => {
// The host's chapter re-broadcast (verse 0 for the chapter already shown) is sticky — it
// names the verse currently displayed, so it must not read as a fresh navigation mid-reveal.
it('re-engages the curtain for a verse-0 navigation arriving during the fade-in', () => {
// Verse 0 is an ordinary verse (a chapter superscription), so a verse-0 reference naming a
// different verse than the one shown is a real mid-reveal navigation — e.g. the host's `<` from
// verse 1 landing on the superscription. It re-engages the curtain like any other external move
// arriving while the new book is still fading in, rather than fading the fresh content twice.
const { result, rerender, setRef } = renderNavMutable({
book: 'GEN',
chapterNum: 1,
Expand All @@ -559,7 +539,7 @@ describe('InterlinearNavContext', () => {
act(() => setRef({ book: 'ZEP', chapterNum: 3, verseNum: 0 }));
rerender();

expect(result.current.fadePhase).toBe('in');
expect(result.current.fadePhase).toBe('out');
});

it('clears the pending fade-in timer on unmount', () => {
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/components/Interlinearizer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -535,8 +535,8 @@ describe('Interlinearizer', () => {

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.
// 'internal' origin records a nav marker so the segment window skips the recenter fade for our
// own move. Default scrRef is GEN 1:1, so this is a real verse change.
const mockNavigate = jest.fn();
renderInterlinearizer({ book: GEN_SUPERSCRIPTION_BOOK, navigate: mockNavigate });

Expand Down
98 changes: 97 additions & 1 deletion src/__tests__/components/InterlinearizerLoader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,102 @@ describe('InterlinearizerLoader', () => {
});
});

it('resolves an out-of-range verse to the last segment of the same chapter', async () => {
// The host's next-verse button emits verseNum + 1 without clamping, so bumping forward from
// the last verse of a chapter delivers a verse past the chapter's end. The loader resolves it
// to the chapter's last segment rather than letting the views fall back to the start of the
// book. Chapter 4 exists so the test proves resolution stays within the requested chapter.
const multiSegmentBook: Book = {
id: 'GEN',
bookRef: 'GEN',
textVersion: 'v1',
segments: [
{
id: 'GEN 3:1',
startRef: { book: 'GEN', chapter: 3, verse: 1 },
endRef: { book: 'GEN', chapter: 3, verse: 1 },
baselineText: 'First verse.',
tokens: [],
},
{
id: 'GEN 3:2',
startRef: { book: 'GEN', chapter: 3, verse: 2 },
endRef: { book: 'GEN', chapter: 3, verse: 2 },
baselineText: 'Last verse of the chapter.',
tokens: [],
},
{
id: 'GEN 4:1',
startRef: { book: 'GEN', chapter: 4, verse: 1 },
endRef: { book: 'GEN', chapter: 4, verse: 1 },
baselineText: 'Next chapter.',
tokens: [],
},
],
};
mockBookData({ book: multiSegmentBook });

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

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

it('resolves a mid-segment verse to the owning segment start', async () => {
// A merged segment can span several verses; navigating to a verse inside the span has no
// segment starting at that verse, so the loader resolves to the nearest preceding segment
// start — the segment that contains the verse, never a later segment in the chapter.
const mergedSegmentBook: Book = {
id: 'GEN',
bookRef: 'GEN',
textVersion: 'v1',
segments: [
{
id: 'GEN 3:1',
startRef: { book: 'GEN', chapter: 3, verse: 1 },
endRef: { book: 'GEN', chapter: 3, verse: 2 },
baselineText: 'Two verses merged into one segment.',
tokens: [],
},
{
id: 'GEN 3:3',
startRef: { book: 'GEN', chapter: 3, verse: 3 },
endRef: { book: 'GEN', chapter: 3, verse: 3 },
baselineText: 'Verse after the merged segment.',
tokens: [],
},
],
};
mockBookData({ book: mergedSegmentBook });

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

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

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.
Expand Down Expand Up @@ -1310,7 +1406,7 @@ describe('InterlinearizerLoader', () => {

/**
* Builds a scroll-group hook whose reference can be restaged between rerenders. A fresh object
* identity is required each change so the provider's `liveScrRef` memo recomputes.
* identity is required each change so the provider adopts it as a new `scrRef`.
*
* @param initial - The reference reported on the first render.
* @returns A `[hook, setRef]` pair.
Expand Down
Loading
Loading