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
22 changes: 18 additions & 4 deletions __mocks__/papi-frontend-react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
);
},
},
),
);
Expand Down
53 changes: 49 additions & 4 deletions src/__tests__/components/ContinuousView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}) => (
<button
data-focus-state={isFocused ? 'focused' : 'default'}
data-phrase-box="true"
onClick={() => onClick?.(index)}
type="button"
>
{tokens.map((t) => t.surfaceText).join(' ')}
</button>
),
}));

jest.mock('../../components/TokenChip', () => ({
__esModule: true,
default: ({ token }: { token: Token }) => <span>{token.surfaceText}</span>,
TokenChip: ({ token }: { token: Token }) => <span>{token.surfaceText}</span>,
}));

// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -71,7 +101,12 @@ function makeBook(overrides?: Partial<Book>): 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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
112 changes: 53 additions & 59 deletions src/__tests__/components/Interlinearizer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};

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<string, unknown>) => {
Expand All @@ -19,6 +26,18 @@ jest.mock('../../components/ContinuousView', () => ({
},
}));

jest.mock('../../components/SegmentView', () => ({
__esModule: true,
SegmentView: ({ segment, ...rest }: CapturedSegmentViewProps) => {
capturedSegmentViewPropsList.push({ segment, ...rest });
return <div data-testid="segment-view" data-segment-id={segment.id} />;
},
default: ({ segment, ...rest }: CapturedSegmentViewProps) => {
capturedSegmentViewPropsList.push({ segment, ...rest });
return <div data-testid="segment-view" data-segment-id={segment.id} />;
},
}));

const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 };

/** Pre-built Book with one GEN 1:1 segment. */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand 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', () => {
Expand All @@ -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);
});
Expand All @@ -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 });

Expand Down
Loading
Loading