From 601a26a69ffe4e5fb3056cb351df16d6d059820f Mon Sep 17 00:00:00 2001 From: alex-rawlings-yyc Date: Tue, 12 May 2026 11:44:22 -0600 Subject: [PATCH 1/7] Reduce test coverage redundancies, audit docstrings --- __mocks__/papi-frontend-react.ts | 22 +- .../components/ContinuousView.test.tsx | 32 +- .../components/Interlinearizer.test.tsx | 99 +++--- .../components/InterlinearizerLoader.test.tsx | 313 +++++++----------- src/__tests__/components/SegmentView.test.tsx | 21 +- src/__tests__/components/TokenChip.test.tsx | 56 ++++ .../hooks/useOptimisticBooleanSetting.test.ts | 8 +- .../parsers/papi/bookTokenizer.test.ts | 19 +- .../parsers/papi/usjBookExtractor.test.ts | 36 +- .../parsers/pt9/interlinearXmlParser.test.ts | 138 +++++--- src/components/ContinuousView.tsx | 27 +- src/components/Interlinearizer.tsx | 4 +- src/components/InterlinearizerLoader.tsx | 5 +- src/components/ScriptureNavControls.tsx | 19 ++ src/components/SegmentView.tsx | 9 +- src/hooks/useInterlinearizerBookData.ts | 17 + src/main.ts | 2 + src/parsers/papi/bookTokenizer.ts | 7 +- src/parsers/papi/usjBookExtractor.ts | 4 + src/parsers/pt9/interlinearXmlParser.ts | 21 +- src/types/interlinearizer.d.ts | 93 ++++-- 21 files changed, 588 insertions(+), 364 deletions(-) create mode 100644 src/__tests__/components/TokenChip.test.tsx diff --git a/__mocks__/papi-frontend-react.ts b/__mocks__/papi-frontend-react.ts index 52c6e6c0..198d68d7 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) { + if (KNOWN_PROJECT_DATA_METHODS.has(prop)) { + return () => [undefined, jest.fn(), false]; + } + throw new Error( + `useProjectData mock: unknown method "${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..3baca392 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 // --------------------------------------------------------------------------- diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index 1f5cf64f..68f0e712 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,6 @@ 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, - }, - ], - }, - ], -}; - function renderInterlinearizer({ book = GEN_1_1_BOOK, bookSegments = GEN_1_1_BOOK.segments, @@ -142,12 +136,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 +151,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 +215,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); }); 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; + }) => ( +