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; + }) => ( +