Skip to content

Commit 103d275

Browse files
Reduce test coverage redundancies, audit docstrings (#59)
* Reduce test coverage redundancies, audit docstrings * Improve PT9 XML Parser error handling, reflow some tests, improve docs * Fix lint error * Fix nitpick * Fix tests * Update `PhraseBox` docs * Fix Prettier error
1 parent 4fd585e commit 103d275

24 files changed

Lines changed: 763 additions & 378 deletions

__mocks__/papi-frontend-react.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,29 @@
44
*/
55

66
/**
7-
* Mock for `useProjectData`. Returns a `Proxy` whose property accesses each yield a function
8-
* returning `[undefined, jest.fn(), false]`, matching the real hook's `[data, setter, isLoading]`
9-
* tuple without requiring a live data provider.
7+
* Known data-provider method names exposed by this mock. Tests that call an unlisted method will
8+
* receive a descriptive error rather than silently returning `undefined`, which mirrors the real
9+
* PAPI behaviour where requesting an unsupported provider key is a programmer error.
10+
*/
11+
const KNOWN_PROJECT_DATA_METHODS = new Set(['BookUSJ']);
12+
13+
/**
14+
* Mock for `useProjectData`. Returns an object whose known methods each return
15+
* `[undefined, jest.fn(), false]`, matching the real hook's `[data, setter, isLoading]` tuple.
16+
* Accessing an unknown method throws to catch misspelled provider keys in tests.
1017
*/
1118
const useProjectData = jest.fn(() =>
1219
new Proxy(
1320
{},
1421
{
15-
get: () => () => [undefined, jest.fn(), false],
22+
get(_target, prop: string | symbol) {
23+
if (typeof prop === 'string' && KNOWN_PROJECT_DATA_METHODS.has(prop)) {
24+
return () => [undefined, jest.fn(), false];
25+
}
26+
throw new Error(
27+
`useProjectData mock: unknown method "${String(prop)}". Add it to KNOWN_PROJECT_DATA_METHODS if intentional.`,
28+
);
29+
},
1630
},
1731
),
1832
);

src/__tests__/components/ContinuousView.test.tsx

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,39 @@
44

55
import { act, render, screen } from '@testing-library/react';
66
import userEvent from '@testing-library/user-event';
7-
import type { Book } from 'interlinearizer';
7+
import type { Book, Token } from 'interlinearizer';
88
import ContinuousView from '../../components/ContinuousView';
99

10+
jest.mock('../../components/PhraseBox', () => ({
11+
__esModule: true,
12+
default: ({
13+
isFocused = false,
14+
index,
15+
onClick,
16+
tokens,
17+
}: {
18+
isFocused?: boolean;
19+
index?: number;
20+
onClick?: (index?: number) => void;
21+
tokens: Token[];
22+
}) => (
23+
<button
24+
data-focus-state={isFocused ? 'focused' : 'default'}
25+
data-phrase-box="true"
26+
onClick={() => onClick?.(index)}
27+
type="button"
28+
>
29+
{tokens.map((t) => t.surfaceText).join(' ')}
30+
</button>
31+
),
32+
}));
33+
34+
jest.mock('../../components/TokenChip', () => ({
35+
__esModule: true,
36+
default: ({ token }: { token: Token }) => <span>{token.surfaceText}</span>,
37+
TokenChip: ({ token }: { token: Token }) => <span>{token.surfaceText}</span>,
38+
}));
39+
1040
// ---------------------------------------------------------------------------
1141
// Test fixtures
1242
// ---------------------------------------------------------------------------
@@ -71,7 +101,12 @@ function makeBook(overrides?: Partial<Book>): Book {
71101
};
72102
}
73103

74-
/** A two-chapter book: chapter 1 has one segment, chapter 2 has one segment. */
104+
/**
105+
* Builds a two-chapter Book fixture: chapter 1 has one segment ("Alpha"), chapter 2 has one segment
106+
* ("Beta"). Used to exercise cross-chapter traversal and verse-jump behaviour.
107+
*
108+
* @returns A two-chapter Book.
109+
*/
75110
function makeTwoChapterBook(): Book {
76111
return {
77112
id: 'GEN',
@@ -114,7 +149,12 @@ function makeTwoChapterBook(): Book {
114149
};
115150
}
116151

117-
/** A book with exactly one token (minimal edge case). */
152+
/**
153+
* Builds a Book with exactly one word token in one segment. Used to assert that both navigation
154+
* arrows are disabled when the strip has nowhere to move.
155+
*
156+
* @returns A single-token Book.
157+
*/
118158
function makeSingleTokenBook(): Book {
119159
return {
120160
id: 'GEN',
@@ -188,7 +228,12 @@ function makeMixedBook(): Book {
188228
};
189229
}
190230

191-
/** A book where every token is non-word, so phraseEntries is empty. */
231+
/**
232+
* Builds a Book whose only token is punctuation, so phraseEntries is empty. Used to exercise the
233+
* code path where ContinuousView renders with no word tokens to navigate between.
234+
*
235+
* @returns A word-free Book.
236+
*/
192237
function makeWordFreeBook(): Book {
193238
return {
194239
id: 'GEN',

src/__tests__/components/Interlinearizer.test.tsx

Lines changed: 53 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@
44

55
import type { SerializedVerseRef } from '@sillsdev/scripture';
66
import { render, screen } from '@testing-library/react';
7-
import userEvent from '@testing-library/user-event';
8-
import type { Book } from 'interlinearizer';
7+
import type { Book, Segment } from 'interlinearizer';
98
import Interlinearizer from '../../components/Interlinearizer';
109

11-
// Store captured props so tests can simulate callbacks
10+
// Store captured props so tests can inspect what Interlinearizer passes down
1211
let capturedContinuousViewProps: Record<string, unknown> = {};
1312

13+
type CapturedSegmentViewProps = {
14+
segment: Segment;
15+
displayMode?: string;
16+
isActive?: boolean;
17+
onClick?: (ref: { book: string; chapter: number; verse: number }) => void;
18+
};
19+
let capturedSegmentViewPropsList: CapturedSegmentViewProps[] = [];
20+
1421
jest.mock('../../components/ContinuousView', () => ({
1522
__esModule: true,
1623
default: (props: Record<string, unknown>) => {
@@ -19,6 +26,18 @@ jest.mock('../../components/ContinuousView', () => ({
1926
},
2027
}));
2128

29+
jest.mock('../../components/SegmentView', () => ({
30+
__esModule: true,
31+
SegmentView: ({ segment, ...rest }: CapturedSegmentViewProps) => {
32+
capturedSegmentViewPropsList.push({ segment, ...rest });
33+
return <div data-testid="segment-view" data-segment-id={segment.id} />;
34+
},
35+
default: ({ segment, ...rest }: CapturedSegmentViewProps) => {
36+
capturedSegmentViewPropsList.push({ segment, ...rest });
37+
return <div data-testid="segment-view" data-segment-id={segment.id} />;
38+
},
39+
}));
40+
2241
const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 };
2342

2443
/** Pre-built Book with one GEN 1:1 segment. */
@@ -90,31 +109,13 @@ const GEN_1_MULTI_BOOK: Book = {
90109
],
91110
};
92111

93-
/** Book with a non-word (punctuation) token — exercises the non-word chip branch. */
94-
const GEN_1_1_PUNCTUATION_BOOK: Book = {
95-
id: 'GEN',
96-
bookRef: 'GEN',
97-
textVersion: 'v1',
98-
segments: [
99-
{
100-
id: 'GEN 1:1',
101-
startRef: { book: 'GEN', chapter: 1, verse: 1 },
102-
endRef: { book: 'GEN', chapter: 1, verse: 1 },
103-
baselineText: '.',
104-
tokens: [
105-
{
106-
id: 'GEN 1:1:0',
107-
surfaceText: '.',
108-
writingSystem: 'en',
109-
type: 'punctuation',
110-
charStart: 0,
111-
charEnd: 1,
112-
},
113-
],
114-
},
115-
],
116-
};
117-
112+
/**
113+
* Renders an Interlinearizer component with sensible defaults, allowing individual props to be
114+
* overridden per test.
115+
*
116+
* @param options - Partial props to merge over the defaults.
117+
* @returns The render result from @testing-library/react.
118+
*/
118119
function renderInterlinearizer({
119120
book = GEN_1_1_BOOK,
120121
bookSegments = GEN_1_1_BOOK.segments,
@@ -142,12 +143,13 @@ function renderInterlinearizer({
142143
describe('Interlinearizer', () => {
143144
beforeEach(() => {
144145
capturedContinuousViewProps = {};
146+
capturedSegmentViewPropsList = [];
145147
});
146148

147-
it('renders token chips when the tokenized book has a segment for the current reference', () => {
149+
it('renders a SegmentView when the tokenized book has a segment for the current reference', () => {
148150
renderInterlinearizer();
149151

150-
expect(screen.getByText('In')).toBeInTheDocument();
152+
expect(screen.getAllByTestId('segment-view')).toHaveLength(1);
151153
});
152154

153155
it('shows a no-verse message when the tokenized book has no segments at all', () => {
@@ -156,56 +158,48 @@ describe('Interlinearizer', () => {
156158
expect(screen.getByText(/no verse data for gen 1\./i)).toBeInTheDocument();
157159
});
158160

159-
it('renders all segments in the current chapter', () => {
161+
it('renders a SegmentView for every segment in the current chapter', () => {
160162
renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments });
161163

162-
expect(screen.getByText('In')).toBeInTheDocument();
163-
expect(screen.getByText('And')).toBeInTheDocument();
164+
expect(screen.getAllByTestId('segment-view')).toHaveLength(2);
165+
expect(capturedSegmentViewPropsList[0].segment.id).toBe('GEN 1:1');
166+
expect(capturedSegmentViewPropsList[1].segment.id).toBe('GEN 1:2');
164167
});
165168

166-
it('highlights only the segment matching the current verse', () => {
167-
const { container } = renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments });
169+
it('passes isActive=true only to the segment matching the current verse', () => {
170+
renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments });
168171

169-
// defaultScrRef is GEN 1:1, so verse 1 is active
170-
const activeSegments = container.querySelectorAll('button[aria-current="true"]');
171-
expect(activeSegments).toHaveLength(1);
172+
// defaultScrRef is GEN 1:1
173+
expect(capturedSegmentViewPropsList[0].isActive).toBe(true);
174+
expect(capturedSegmentViewPropsList[1].isActive).toBeFalsy();
172175
});
173176

174-
it('shows all chapter segments when navigating to a title reference (verse 0)', () => {
177+
it('renders all segments when navigating to a title reference (verse 0)', () => {
175178
const titleRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 0 };
176179
renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, scrRef: titleRef });
177180

178-
expect(screen.getByText('In')).toBeInTheDocument();
179-
expect(screen.getByText('And')).toBeInTheDocument();
180-
});
181-
182-
it('renders non-word tokens as muted chips', () => {
183-
renderInterlinearizer({ bookSegments: GEN_1_1_PUNCTUATION_BOOK.segments });
184-
185-
expect(screen.getByText('.')).toBeInTheDocument();
181+
expect(screen.getAllByTestId('segment-view')).toHaveLength(2);
186182
});
187183

188-
it('calls setScrRef with the segment ref when a verse box is clicked', async () => {
184+
it('calls setScrRef with the segment ref when a verse box is clicked', () => {
189185
const mockSetScrRef = jest.fn();
190186
renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, setScrRef: mockSetScrRef });
191187

192-
await userEvent.click(screen.getByText('And'));
188+
capturedSegmentViewPropsList[1].onClick?.({ book: 'GEN', chapter: 1, verse: 2 });
193189

194190
expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 });
195191
});
196192

197-
it('renders segments in baseline-text mode when continuousScroll is true', () => {
193+
it('passes displayMode="baseline-text" to SegmentView when continuousScroll is true', () => {
198194
renderInterlinearizer({ continuousScroll: true });
199195

200-
expect(screen.getByText('In the beginning.')).toBeInTheDocument();
201-
expect(screen.queryByText('In')).not.toBeInTheDocument();
196+
expect(capturedSegmentViewPropsList[0].displayMode).toBe('baseline-text');
202197
});
203198

204-
it('renders all chapter segments in baseline-text mode when continuousScroll is true', () => {
199+
it('passes displayMode="baseline-text" to all SegmentViews when continuousScroll is true', () => {
205200
renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true });
206201

207-
expect(screen.getByText('In the beginning.')).toBeInTheDocument();
208-
expect(screen.getByText('And the earth.')).toBeInTheDocument();
202+
capturedSegmentViewPropsList.forEach((p) => expect(p.displayMode).toBe('baseline-text'));
209203
});
210204

211205
it('renders ContinuousView when continuousScroll is true', () => {
@@ -228,7 +222,7 @@ describe('Interlinearizer', () => {
228222

229223
const continuousView = screen.getByTestId('continuous-view');
230224
const allElements = Array.from(
231-
container.querySelectorAll('[data-testid="continuous-view"], button[aria-current]'),
225+
container.querySelectorAll('[data-testid="continuous-view"], [data-testid="segment-view"]'),
232226
);
233227
expect(allElements[0]).toBe(continuousView);
234228
});
@@ -239,9 +233,9 @@ describe('Interlinearizer', () => {
239233

240234
expect(screen.getByTestId('continuous-view')).toBeInTheDocument();
241235

242-
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-type-assertion/no-type-assertion
243-
const onVerseChange = capturedContinuousViewProps.onVerseChange as any;
244-
expect(onVerseChange).toBeDefined();
236+
const { onVerseChange } = capturedContinuousViewProps;
237+
if (typeof onVerseChange !== 'function')
238+
throw new Error('Expected onVerseChange to be a function');
245239

246240
onVerseChange({ book: 'GEN', chapter: 2, verse: 3 });
247241

0 commit comments

Comments
 (0)