diff --git a/__mocks__/papi-frontend-react.ts b/__mocks__/papi-frontend-react.ts
index 52c6e6c0..e7f99bb6 100644
--- a/__mocks__/papi-frontend-react.ts
+++ b/__mocks__/papi-frontend-react.ts
@@ -4,15 +4,29 @@
*/
/**
- * Mock for `useProjectData`. Returns a `Proxy` whose property accesses each yield a function
- * returning `[undefined, jest.fn(), false]`, matching the real hook's `[data, setter, isLoading]`
- * tuple without requiring a live data provider.
+ * Known data-provider method names exposed by this mock. Tests that call an unlisted method will
+ * receive a descriptive error rather than silently returning `undefined`, which mirrors the real
+ * PAPI behaviour where requesting an unsupported provider key is a programmer error.
+ */
+const KNOWN_PROJECT_DATA_METHODS = new Set(['BookUSJ']);
+
+/**
+ * Mock for `useProjectData`. Returns an object whose known methods each return
+ * `[undefined, jest.fn(), false]`, matching the real hook's `[data, setter, isLoading]` tuple.
+ * Accessing an unknown method throws to catch misspelled provider keys in tests.
*/
const useProjectData = jest.fn(() =>
new Proxy(
{},
{
- get: () => () => [undefined, jest.fn(), false],
+ get(_target, prop: string | symbol) {
+ if (typeof prop === 'string' && KNOWN_PROJECT_DATA_METHODS.has(prop)) {
+ return () => [undefined, jest.fn(), false];
+ }
+ throw new Error(
+ `useProjectData mock: unknown method "${String(prop)}". Add it to KNOWN_PROJECT_DATA_METHODS if intentional.`,
+ );
+ },
},
),
);
diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx
index c5a68678..25d2dec9 100644
--- a/src/__tests__/components/ContinuousView.test.tsx
+++ b/src/__tests__/components/ContinuousView.test.tsx
@@ -4,9 +4,39 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import type { Book } from 'interlinearizer';
+import type { Book, Token } from 'interlinearizer';
import ContinuousView from '../../components/ContinuousView';
+jest.mock('../../components/PhraseBox', () => ({
+ __esModule: true,
+ default: ({
+ isFocused = false,
+ index,
+ onClick,
+ tokens,
+ }: {
+ isFocused?: boolean;
+ index?: number;
+ onClick?: (index?: number) => void;
+ tokens: Token[];
+ }) => (
+
+ ),
+}));
+
+jest.mock('../../components/TokenChip', () => ({
+ __esModule: true,
+ default: ({ token }: { token: Token }) => {token.surfaceText},
+ TokenChip: ({ token }: { token: Token }) => {token.surfaceText},
+}));
+
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
@@ -71,7 +101,12 @@ function makeBook(overrides?: Partial): Book {
};
}
-/** A two-chapter book: chapter 1 has one segment, chapter 2 has one segment. */
+/**
+ * Builds a two-chapter Book fixture: chapter 1 has one segment ("Alpha"), chapter 2 has one segment
+ * ("Beta"). Used to exercise cross-chapter traversal and verse-jump behaviour.
+ *
+ * @returns A two-chapter Book.
+ */
function makeTwoChapterBook(): Book {
return {
id: 'GEN',
@@ -114,7 +149,12 @@ function makeTwoChapterBook(): Book {
};
}
-/** A book with exactly one token (minimal edge case). */
+/**
+ * Builds a Book with exactly one word token in one segment. Used to assert that both navigation
+ * arrows are disabled when the strip has nowhere to move.
+ *
+ * @returns A single-token Book.
+ */
function makeSingleTokenBook(): Book {
return {
id: 'GEN',
@@ -188,7 +228,12 @@ function makeMixedBook(): Book {
};
}
-/** A book where every token is non-word, so phraseEntries is empty. */
+/**
+ * Builds a Book whose only token is punctuation, so phraseEntries is empty. Used to exercise the
+ * code path where ContinuousView renders with no word tokens to navigate between.
+ *
+ * @returns A word-free Book.
+ */
function makeWordFreeBook(): Book {
return {
id: 'GEN',
diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx
index 1f5cf64f..a9c2ad1f 100644
--- a/src/__tests__/components/Interlinearizer.test.tsx
+++ b/src/__tests__/components/Interlinearizer.test.tsx
@@ -4,13 +4,20 @@
import type { SerializedVerseRef } from '@sillsdev/scripture';
import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import type { Book } from 'interlinearizer';
+import type { Book, Segment } from 'interlinearizer';
import Interlinearizer from '../../components/Interlinearizer';
-// Store captured props so tests can simulate callbacks
+// Store captured props so tests can inspect what Interlinearizer passes down
let capturedContinuousViewProps: Record = {};
+type CapturedSegmentViewProps = {
+ segment: Segment;
+ displayMode?: string;
+ isActive?: boolean;
+ onClick?: (ref: { book: string; chapter: number; verse: number }) => void;
+};
+let capturedSegmentViewPropsList: CapturedSegmentViewProps[] = [];
+
jest.mock('../../components/ContinuousView', () => ({
__esModule: true,
default: (props: Record) => {
@@ -19,6 +26,18 @@ jest.mock('../../components/ContinuousView', () => ({
},
}));
+jest.mock('../../components/SegmentView', () => ({
+ __esModule: true,
+ SegmentView: ({ segment, ...rest }: CapturedSegmentViewProps) => {
+ capturedSegmentViewPropsList.push({ segment, ...rest });
+ return ;
+ },
+ default: ({ segment, ...rest }: CapturedSegmentViewProps) => {
+ capturedSegmentViewPropsList.push({ segment, ...rest });
+ return ;
+ },
+}));
+
const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 };
/** Pre-built Book with one GEN 1:1 segment. */
@@ -90,31 +109,13 @@ const GEN_1_MULTI_BOOK: Book = {
],
};
-/** Book with a non-word (punctuation) token — exercises the non-word chip branch. */
-const GEN_1_1_PUNCTUATION_BOOK: Book = {
- id: 'GEN',
- bookRef: 'GEN',
- textVersion: 'v1',
- segments: [
- {
- id: 'GEN 1:1',
- startRef: { book: 'GEN', chapter: 1, verse: 1 },
- endRef: { book: 'GEN', chapter: 1, verse: 1 },
- baselineText: '.',
- tokens: [
- {
- id: 'GEN 1:1:0',
- surfaceText: '.',
- writingSystem: 'en',
- type: 'punctuation',
- charStart: 0,
- charEnd: 1,
- },
- ],
- },
- ],
-};
-
+/**
+ * Renders an Interlinearizer component with sensible defaults, allowing individual props to be
+ * overridden per test.
+ *
+ * @param options - Partial props to merge over the defaults.
+ * @returns The render result from @testing-library/react.
+ */
function renderInterlinearizer({
book = GEN_1_1_BOOK,
bookSegments = GEN_1_1_BOOK.segments,
@@ -142,12 +143,13 @@ function renderInterlinearizer({
describe('Interlinearizer', () => {
beforeEach(() => {
capturedContinuousViewProps = {};
+ capturedSegmentViewPropsList = [];
});
- it('renders token chips when the tokenized book has a segment for the current reference', () => {
+ it('renders a SegmentView when the tokenized book has a segment for the current reference', () => {
renderInterlinearizer();
- expect(screen.getByText('In')).toBeInTheDocument();
+ expect(screen.getAllByTestId('segment-view')).toHaveLength(1);
});
it('shows a no-verse message when the tokenized book has no segments at all', () => {
@@ -156,56 +158,48 @@ describe('Interlinearizer', () => {
expect(screen.getByText(/no verse data for gen 1\./i)).toBeInTheDocument();
});
- it('renders all segments in the current chapter', () => {
+ it('renders a SegmentView for every segment in the current chapter', () => {
renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments });
- expect(screen.getByText('In')).toBeInTheDocument();
- expect(screen.getByText('And')).toBeInTheDocument();
+ expect(screen.getAllByTestId('segment-view')).toHaveLength(2);
+ expect(capturedSegmentViewPropsList[0].segment.id).toBe('GEN 1:1');
+ expect(capturedSegmentViewPropsList[1].segment.id).toBe('GEN 1:2');
});
- it('highlights only the segment matching the current verse', () => {
- const { container } = renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments });
+ it('passes isActive=true only to the segment matching the current verse', () => {
+ renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments });
- // defaultScrRef is GEN 1:1, so verse 1 is active
- const activeSegments = container.querySelectorAll('button[aria-current="true"]');
- expect(activeSegments).toHaveLength(1);
+ // defaultScrRef is GEN 1:1
+ expect(capturedSegmentViewPropsList[0].isActive).toBe(true);
+ expect(capturedSegmentViewPropsList[1].isActive).toBeFalsy();
});
- it('shows all chapter segments when navigating to a title reference (verse 0)', () => {
+ it('renders all segments when navigating to a title reference (verse 0)', () => {
const titleRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 0 };
renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, scrRef: titleRef });
- expect(screen.getByText('In')).toBeInTheDocument();
- expect(screen.getByText('And')).toBeInTheDocument();
- });
-
- it('renders non-word tokens as muted chips', () => {
- renderInterlinearizer({ bookSegments: GEN_1_1_PUNCTUATION_BOOK.segments });
-
- expect(screen.getByText('.')).toBeInTheDocument();
+ expect(screen.getAllByTestId('segment-view')).toHaveLength(2);
});
- it('calls setScrRef with the segment ref when a verse box is clicked', async () => {
+ it('calls setScrRef with the segment ref when a verse box is clicked', () => {
const mockSetScrRef = jest.fn();
renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, setScrRef: mockSetScrRef });
- await userEvent.click(screen.getByText('And'));
+ capturedSegmentViewPropsList[1].onClick?.({ book: 'GEN', chapter: 1, verse: 2 });
expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 });
});
- it('renders segments in baseline-text mode when continuousScroll is true', () => {
+ it('passes displayMode="baseline-text" to SegmentView when continuousScroll is true', () => {
renderInterlinearizer({ continuousScroll: true });
- expect(screen.getByText('In the beginning.')).toBeInTheDocument();
- expect(screen.queryByText('In')).not.toBeInTheDocument();
+ expect(capturedSegmentViewPropsList[0].displayMode).toBe('baseline-text');
});
- it('renders all chapter segments in baseline-text mode when continuousScroll is true', () => {
+ it('passes displayMode="baseline-text" to all SegmentViews when continuousScroll is true', () => {
renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true });
- expect(screen.getByText('In the beginning.')).toBeInTheDocument();
- expect(screen.getByText('And the earth.')).toBeInTheDocument();
+ capturedSegmentViewPropsList.forEach((p) => expect(p.displayMode).toBe('baseline-text'));
});
it('renders ContinuousView when continuousScroll is true', () => {
@@ -228,7 +222,7 @@ describe('Interlinearizer', () => {
const continuousView = screen.getByTestId('continuous-view');
const allElements = Array.from(
- container.querySelectorAll('[data-testid="continuous-view"], button[aria-current]'),
+ container.querySelectorAll('[data-testid="continuous-view"], [data-testid="segment-view"]'),
);
expect(allElements[0]).toBe(continuousView);
});
@@ -239,9 +233,9 @@ describe('Interlinearizer', () => {
expect(screen.getByTestId('continuous-view')).toBeInTheDocument();
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-type-assertion/no-type-assertion
- const onVerseChange = capturedContinuousViewProps.onVerseChange as any;
- expect(onVerseChange).toBeDefined();
+ const { onVerseChange } = capturedContinuousViewProps;
+ if (typeof onVerseChange !== 'function')
+ throw new Error('Expected onVerseChange to be a function');
onVerseChange({ book: 'GEN', chapter: 2, verse: 3 });
diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx
index 669fadf2..4e422961 100644
--- a/src/__tests__/components/InterlinearizerLoader.test.tsx
+++ b/src/__tests__/components/InterlinearizerLoader.test.tsx
@@ -2,60 +2,72 @@
///
///
-import {
- useLocalizedStrings,
- useProjectData,
- useProjectSetting,
- useRecentScriptureRefs,
-} from '@papi/frontend/react';
+import { useLocalizedStrings } from '@papi/frontend/react';
import type { SerializedVerseRef } from '@sillsdev/scripture';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { Book } from 'interlinearizer';
-import { tokenizeBook } from 'parsers/papi/bookTokenizer';
-import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor';
+import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData';
+import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting';
import InterlinearizerLoader from '../../components/InterlinearizerLoader';
-jest.mock('parsers/papi/bookTokenizer');
-jest.mock('parsers/papi/usjBookExtractor');
+jest.mock('../../hooks/useInterlinearizerBookData');
+jest.mock('../../hooks/useOptimisticBooleanSetting');
+
+jest.mock('../../components/ContinuousScrollToggle', () => ({
+ __esModule: true,
+ default: ({
+ checked,
+ disabled,
+ onCheckedChange,
+ }: {
+ checked: boolean;
+ disabled: boolean;
+ onCheckedChange: (v: boolean) => void;
+ }) => (
+