diff --git a/__mocks__/lucide-react.tsx b/__mocks__/lucide-react.tsx index dc182796..e2b2bd69 100644 --- a/__mocks__/lucide-react.tsx +++ b/__mocks__/lucide-react.tsx @@ -5,21 +5,32 @@ import type { ReactElement } from 'react'; /** - * Stub for the Trash2 icon; renders a bare SVG element so tests can locate the icon by test ID. + * Stub for the LocateFixed icon; renders a bare SVG element so tests can locate the icon by test + * ID. * - * @param props - SVG props forwarded from the component, including optional className and size. - * @returns A ReactElement SVG element used as a trash icon stub in tests. + * @param props - SVG props forwarded from the component, including optional className. + * @returns A ReactElement SVG element used as a locate-fixed icon stub in tests. */ -export function Trash2(props: Readonly<{ className?: string; size?: number }>): ReactElement { - return ; +export function LocateFixed(props: Readonly<{ className?: string }>): ReactElement { + return ; } /** - * Stub for the Info icon; renders a bare SVG element so tests can locate the icon by test ID. + * Stub for the Info icon. * - * @param props - SVG props forwarded from the component, including optional className and size. + * @param props - SVG props forwarded from the component. * @returns A ReactElement SVG element used as an info icon stub in tests. */ -export function Info(props: Readonly<{ className?: string; size?: number }>): ReactElement { +export function Info(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; } + +/** + * Stub for the Trash2 icon. + * + * @param props - SVG props forwarded from the component. + * @returns A ReactElement SVG element used as a trash icon stub in tests. + */ +export function Trash2(props: Readonly<{ size?: number; className?: string }>): ReactElement { + return ; +} diff --git a/jest.config.ts b/jest.config.ts index 670457f5..aa0b6040 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -38,6 +38,7 @@ const config: Config = { '!src/**/*.spec.{ts,tsx}', '!src/types/**', '!src/utils/interlinear-project-summary.ts', + '!src/components/component-types.ts', ], /** Directory for coverage output. */ diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx new file mode 100644 index 00000000..4820a791 --- /dev/null +++ b/src/__tests__/components/AnalysisStore.test.tsx @@ -0,0 +1,368 @@ +/** @file Unit tests for components/AnalysisStore.tsx. */ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { TextAnalysis, TokenAnalysis, TokenAnalysisLink } from 'interlinearizer'; +import { + AnalysisStoreProvider, + useAnalysis, + useGloss, + useGlossDispatch, +} from '../../components/AnalysisStore'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Builds a minimal `TextAnalysis` with a single approved `TokenAnalysis` for the given token. + * + * @param tokenRef - Token reference string. + * @param gloss - Gloss value for the `'und'` language key. + * @param surfaceText - Surface text of the token. + * @returns A `TextAnalysis` seeded with one approved token analysis. + */ +function makeAnalysisWithGloss( + tokenRef: string, + gloss: string, + surfaceText = 'word', +): TextAnalysis { + const ta: TokenAnalysis = { + id: `${tokenRef}-analysis`, + surfaceText, + gloss: { und: gloss }, + }; + const link: TokenAnalysisLink = { + analysisId: ta.id, + status: 'approved', + token: { tokenRef, surfaceText }, + }; + return { + segmentAnalyses: [], + segmentAnalysisLinks: [], + tokenAnalyses: [ta], + tokenAnalysisLinks: [link], + phraseAnalyses: [], + phraseAnalysisLinks: [], + }; +} + +/** + * Renders a component that displays the gloss for a single token, used to assert on `useGloss`. + * + * @param tokenRef - Token ref to subscribe to. + * @returns JSX element suitable for passing to `render`. + */ +function GlossReader({ tokenRef }: Readonly<{ tokenRef: string }>) { + const gloss = useGloss(tokenRef); + return {gloss}; +} + +/** + * Renders a component that displays the full analysis as JSON, used to assert on `useAnalysis`. + * + * @returns JSX element suitable for passing to `render`. + */ +function AnalysisReader() { + const analysis = useAnalysis(); + return {JSON.stringify(analysis)}; +} + +/** + * Renders a component that calls `useGlossDispatch` without a provider, used to assert the hook + * throws outside an {@link AnalysisStoreProvider}. + * + * @returns Nothing — only mounted to trigger the throw. + */ +function DispatchUser() { + useGlossDispatch(); + return undefined; +} + +/** + * Renders a button that calls `useGlossDispatch` to write a gloss, used to test dispatch. + * + * @param props.tokenRef - Token ref to write. + * @param props.surfaceText - Surface text of the token. + * @param props.value - Gloss value to write. + * @returns JSX element suitable for passing to `render`. + */ +function GlossWriter({ + tokenRef, + surfaceText, + value, +}: Readonly<{ tokenRef: string; surfaceText: string; value: string }>) { + const dispatch = useGlossDispatch(); + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useGloss', () => { + it('returns an empty string for an unknown token', () => { + render( + + + , + ); + expect(screen.getByTestId('gloss')).toHaveTextContent(''); + }); + + it('returns the approved gloss from initialAnalysis', () => { + render( + + + , + ); + expect(screen.getByTestId('gloss')).toHaveTextContent('hello'); + }); + + it('returns empty string for a token with a non-approved link in initialAnalysis', () => { + const ta: TokenAnalysis = { id: 'ta-1', surfaceText: 'word', gloss: { en: 'hi' } }; + const link: TokenAnalysisLink = { + analysisId: 'ta-1', + status: 'suggested', + token: { tokenRef: 'tok-1', surfaceText: 'word' }, + }; + const analysis: TextAnalysis = { + segmentAnalyses: [], + segmentAnalysisLinks: [], + tokenAnalyses: [ta], + tokenAnalysisLinks: [link], + phraseAnalyses: [], + phraseAnalysisLinks: [], + }; + render( + + + , + ); + expect(screen.getByTestId('gloss')).toHaveTextContent(''); + }); + + it('updates when the subscribed token is glossed via dispatch', async () => { + render( + + + + , + ); + expect(screen.getByTestId('gloss')).toHaveTextContent(''); + await userEvent.click(screen.getByRole('button', { name: 'write' })); + expect(screen.getByTestId('gloss')).toHaveTextContent('world'); + }); + + it('does not re-render when a different token is glossed', async () => { + let renderCount = 0; + + /** + * Renders the current gloss for a token while counting how many times it re-renders, so tests + * can assert that unrelated gloss changes do not cause extra renders. + * + * @param props - Component props. + * @param props.tokenRef - The token whose approved gloss to read via {@link useGloss}. + * @returns A span containing the gloss string. + * @throws When called outside an {@link AnalysisStoreProvider}. + */ + function CountingGlossReader({ tokenRef }: Readonly<{ tokenRef: string }>) { + renderCount += 1; + const gloss = useGloss(tokenRef); + return {gloss}; + } + + render( + + + + , + ); + const initialRenderCount = renderCount; + await userEvent.click(screen.getByRole('button', { name: 'write' })); + expect(renderCount).toBe(initialRenderCount); + }); + + it('uses the analysisLanguage prop to resolve the gloss', () => { + const ta: TokenAnalysis = { id: 'ta-1', surfaceText: 'mot', gloss: { fr: 'bonjour' } }; + const link: TokenAnalysisLink = { + analysisId: 'ta-1', + status: 'approved', + token: { tokenRef: 'tok-1', surfaceText: 'mot' }, + }; + const analysis: TextAnalysis = { + segmentAnalyses: [], + segmentAnalysisLinks: [], + tokenAnalyses: [ta], + tokenAnalysisLinks: [link], + phraseAnalyses: [], + phraseAnalysisLinks: [], + }; + render( + + + , + ); + expect(screen.getByTestId('gloss')).toHaveTextContent('bonjour'); + }); + + it('throws when called outside an AnalysisStoreProvider', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + 'useGloss must be used inside an AnalysisStoreProvider', + ); + }); +}); + +describe('useAnalysis', () => { + it('returns an empty analysis when no initialAnalysis is provided', () => { + render( + + + , + ); + const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); + expect(analysis.tokenAnalyses).toHaveLength(0); + expect(analysis.tokenAnalysisLinks).toHaveLength(0); + }); + + it('returns seeded analyses from initialAnalysis', () => { + const seed = makeAnalysisWithGloss('tok-1', 'hi'); + render( + + + , + ); + const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); + expect(analysis.tokenAnalyses).toHaveLength(1); + expect(analysis.tokenAnalysisLinks).toHaveLength(1); + }); + + it('updates after a gloss write', async () => { + render( + + + + , + ); + await userEvent.click(screen.getByRole('button', { name: 'write' })); + const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); + expect(analysis.tokenAnalyses).toHaveLength(1); + expect(analysis.tokenAnalyses[0].gloss).toStrictEqual({ und: 'world' }); + expect(analysis.tokenAnalysisLinks[0].status).toBe('approved'); + }); + + it('throws when called outside an AnalysisStoreProvider', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + 'useAnalysis must be used inside an AnalysisStoreProvider', + ); + }); +}); + +describe('useGlossDispatch', () => { + it('replaces the existing approved analysis on subsequent writes for the same token', async () => { + render( + + + + , + ); + await userEvent.click(screen.getByRole('button', { name: 'write' })); + await userEvent.click(screen.getByRole('button', { name: 'write' })); + const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); + expect(analysis.tokenAnalyses).toHaveLength(1); + expect(analysis.tokenAnalysisLinks).toHaveLength(1); + expect(analysis.tokenAnalysisLinks[0].status).toBe('approved'); + }); + + it('creates a new approved analysis when writing to a different token', async () => { + render( + + + + + , + ); + await userEvent.click(screen.getAllByRole('button', { name: 'write' })[0]); + await userEvent.click(screen.getAllByRole('button', { name: 'write' })[1]); + const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); + expect(analysis.tokenAnalyses).toHaveLength(2); + expect(analysis.tokenAnalysisLinks).toHaveLength(2); + }); + + it('does not touch existing suggested analyses on write', async () => { + const suggested: TokenAnalysis = { + id: 'suggested-1', + surfaceText: 'word', + gloss: { en: 'old' }, + }; + const suggestedLink: TokenAnalysisLink = { + analysisId: 'suggested-1', + status: 'suggested', + token: { tokenRef: 'tok-1', surfaceText: 'word' }, + }; + const seed: TextAnalysis = { + segmentAnalyses: [], + segmentAnalysisLinks: [], + tokenAnalyses: [suggested], + tokenAnalysisLinks: [suggestedLink], + phraseAnalyses: [], + phraseAnalysisLinks: [], + }; + render( + + + + , + ); + await userEvent.click(screen.getByRole('button', { name: 'write' })); + const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); + expect(analysis.tokenAnalyses).toHaveLength(2); + const suggestedEntry = analysis.tokenAnalysisLinks.find((l) => l.status === 'suggested'); + const approvedEntry = analysis.tokenAnalysisLinks.find((l) => l.status === 'approved'); + expect(suggestedEntry?.analysisId).toBe('suggested-1'); + expect(approvedEntry?.analysisId).not.toBe('suggested-1'); + }); + + it('calls the onGlossChange spy with tokenRef and value', async () => { + const spy = jest.fn(); + render( + + + , + ); + await userEvent.click(screen.getByRole('button', { name: 'write' })); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('tok-1', 'hi'); + }); + + it('calls onSave with the updated TextAnalysis', async () => { + const onSave = jest.fn(); + render( + + + , + ); + await userEvent.click(screen.getByRole('button', { name: 'write' })); + expect(onSave).toHaveBeenCalledTimes(1); + const saved: TextAnalysis = onSave.mock.calls[0][0]; + expect(saved.tokenAnalyses[0].gloss).toStrictEqual({ und: 'hi' }); + }); + + it('throws when called outside an AnalysisStoreProvider', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + 'useGlossDispatch must be used inside an AnalysisStoreProvider', + ); + }); +}); diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index 67d16d1d..e293e3eb 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -4,39 +4,82 @@ import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { Book, Token } from 'interlinearizer'; +import type { Book, ScriptureRef, Token } from 'interlinearizer'; +import type { ReactNode } from 'react'; import ContinuousView from '../../components/ContinuousView'; +import { AnalysisStoreProvider } from '../../components/AnalysisStore'; + +// --------------------------------------------------------------------------- +// AnalysisStore mock — pass-through provider so AnalysisStore.tsx stays out of scope +// --------------------------------------------------------------------------- + +jest.mock('../../components/AnalysisStore', () => ({ + __esModule: true, + /** + * Pass-through provider stub that renders children directly, keeping AnalysisStore.tsx out of + * scope. + * + * @param props - Component props. + * @param props.children - Child nodes to render. + * @returns The children unchanged. + */ + AnalysisStoreProvider({ children }: Readonly<{ children: ReactNode; analysisLanguage: string }>) { + return children; + }, + /** + * Returns a fixed empty gloss string for any token. + * + * @returns An empty string. + */ + useGloss: () => '', + /** + * Returns a no-op dispatch function. + * + * @returns A function that accepts any arguments and does nothing. + */ + useGlossDispatch: () => () => {}, +})); + +/** Render options that wrap every test render in a `AnalysisStoreProvider`. */ +const withAnalysisStore = { + wrapper({ children }: Readonly<{ children: ReactNode }>) { + return {children}; + }, +}; + +jest.mock('../../components/TokenChip', () => ({ + __esModule: true, + MemoizedInertTokenChip({ token }: Readonly<{ token: Token }>) { + return {token.surfaceText}; + }, +})); jest.mock('../../components/PhraseBox', () => ({ __esModule: true, default: ({ - isFocused = false, index, - onClick, + isFocused = false, + onFocusPhrase, tokens, - }: { - isFocused?: boolean; - index?: number; - onClick?: (index?: number) => void; - tokens: Token[]; - }) => ( + }: Readonly<{ + index: number | undefined; + isFocused: boolean; + onFocusPhrase: (index?: number) => void; + tokens: (Token & { type: 'word' })[]; + }>) => ( ), })); -jest.mock('../../components/TokenChip', () => ({ - __esModule: true, - default: ({ token }: { token: Token }) => {token.surfaceText}, - TokenChip: ({ token }: { token: Token }) => {token.surfaceText}, -})); - // --------------------------------------------------------------------------- // Test fixtures // --------------------------------------------------------------------------- @@ -262,10 +305,61 @@ function makeWordFreeBook(): Book { }; } +/** + * Builds a Book with `count` word tokens spread across one segment per token, each in GEN 1:N. Used + * to exercise the phrase-window windowing code paths (PHRASE_WINDOW_HALF = 100 on each side). + * + * @param count - Total number of word-token segments to create. + * @returns A Book with `count` single-token segments. + */ +function makeLargeBook(count: number): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: Array.from({ length: count }, (_, i) => ({ + id: `GEN 1:${i + 1}`, + startRef: { book: 'GEN', chapter: 1, verse: i + 1 }, + endRef: { book: 'GEN', chapter: 1, verse: i + 1 }, + baselineText: `word${i}`, + tokens: [ + { + ref: `large-tok-${i}`, + surfaceText: `word${i}`, + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: String(`word${i}`).length, + }, + ], + })), + }; +} + // --------------------------------------------------------------------------- const scrollIntoViewMock = jest.fn(); +/** + * Minimal required props for ContinuousView. Spread into render calls so tests only need to + * override what they actually care about. + * + * @returns An object containing all required ContinuousView props set to no-op stubs. + */ +function requiredProps(): { + activePhraseIndex: undefined; + activeVerse: ScriptureRef; + onFocusPhraseIndexChange: jest.Mock; + onVerseChange: jest.Mock; +} { + return { + activePhraseIndex: undefined, + activeVerse: { book: 'GEN', chapter: 1, verse: 1 }, + onFocusPhraseIndexChange: jest.fn(), + onVerseChange: jest.fn(), + }; +} + beforeAll(() => { // jsdom does not implement scrollIntoView. HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; @@ -279,9 +373,9 @@ beforeEach(() => { // Rendering // --------------------------------------------------------------------------- -describe('ContinuousView rendering', () => { +describe('ContinuousView initial render', () => { it('renders all tokens from all segments as a flat list', () => { - render(); + render(, withAnalysisStore); expect(screen.getByText('In')).toBeInTheDocument(); expect(screen.getByText('the')).toBeInTheDocument(); @@ -289,15 +383,8 @@ describe('ContinuousView rendering', () => { expect(screen.getByText('God')).toBeInTheDocument(); }); - it('renders tokens from both chapters in a two-chapter book', () => { - render(); - - expect(screen.getByText('Alpha')).toBeInTheDocument(); - expect(screen.getByText('Beta')).toBeInTheDocument(); - }); - it('does not render any verse label or segment separator', () => { - render(); + render(, withAnalysisStore); // No verse numbers or colons that would indicate verse labels expect(screen.queryByText('1:1')).not.toBeInTheDocument(); @@ -307,30 +394,39 @@ describe('ContinuousView rendering', () => { }); it('renders a Previous token button and a Next token button', () => { - render(); + render(, withAnalysisStore); expect(screen.getByRole('button', { name: 'Previous token' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Next token' })).toBeInTheDocument(); }); - it('renders a non-word token via TokenChip within the strip', () => { + it('renders a non-word token via InertTokenChip within the strip', () => { // makeMixedBook: GEN 1:1 has a word token; GEN 1:2 has a punctuation token - render(); + render(, withAnalysisStore); - // Both the word chip ("In") and the punctuation chip (".") must appear + // Both the word chip ("In") and the inert chip (".") must appear expect(screen.getByText('In')).toBeInTheDocument(); expect(screen.getByText('.')).toBeInTheDocument(); }); it('renders without crashing when book has no word tokens (empty phraseEntries)', () => { - render(); + render(, withAnalysisStore); // The punctuation token is rendered expect(screen.getByText('.')).toBeInTheDocument(); }); + it('renders without crashing when book has no word tokens and activePhraseIndex is set', () => { + render( + , + withAnalysisStore, + ); + + expect(screen.getByText('.')).toBeInTheDocument(); + }); + it('clicking an out-of-focus phrase box brings it into focus', async () => { - render(); + render(, withAnalysisStore); const clickedPhraseBox = screen.getByText('beginning').closest('[data-phrase-box="true"]'); if (!clickedPhraseBox) throw new Error('Expected phrase box wrapper for token'); @@ -344,7 +440,7 @@ describe('ContinuousView rendering', () => { }); it('clicking the already-focused phrase box leaves it focused', async () => { - render(); + render(, withAnalysisStore); // The first token is focused by default. const firstPhraseBox = screen.getByText('In').closest('[data-phrase-box="true"]'); @@ -366,26 +462,26 @@ describe('ContinuousView rendering', () => { describe('ContinuousView arrow disabled states', () => { it('disables the prev arrow on initial render (book start)', () => { - render(); + render(, withAnalysisStore); expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); }); it('enables the next arrow on initial render when there are multiple tokens', () => { - render(); + render(, withAnalysisStore); expect(screen.getByRole('button', { name: 'Next token' })).toBeEnabled(); }); it('disables both arrows when the book has exactly one token', () => { - render(); + render(, withAnalysisStore); expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); }); it('enables the prev arrow after clicking next once', async () => { - render(); + render(, withAnalysisStore); await userEvent.click(screen.getByRole('button', { name: 'Next token' })); @@ -393,7 +489,7 @@ describe('ContinuousView arrow disabled states', () => { }); it('disables the next arrow when advanced to the last token', async () => { - render(); + render(, withAnalysisStore); const nextBtn = screen.getByRole('button', { name: 'Next token' }); // 4 tokens total: advance 3 times to reach index 3 (last) @@ -405,7 +501,7 @@ describe('ContinuousView arrow disabled states', () => { }); it('re-enables the next arrow after going prev from the last token', async () => { - render(); + render(, withAnalysisStore); const nextBtn = screen.getByRole('button', { name: 'Next token' }); await userEvent.click(nextBtn); @@ -426,7 +522,10 @@ describe('ContinuousView arrow disabled states', () => { describe('ContinuousView fade overlays', () => { it('does not render prev fade at book start', () => { - const { container } = render(); + const { container } = render( + , + withAnalysisStore, + ); const gradients = container.querySelectorAll('[aria-hidden="true"]'); const prevFades = Array.from(gradients).filter((el) => @@ -436,7 +535,10 @@ describe('ContinuousView fade overlays', () => { }); it('renders next fade at book start (next side is enabled)', () => { - const { container } = render(); + const { container } = render( + , + withAnalysisStore, + ); const gradients = container.querySelectorAll('[aria-hidden="true"]'); const nextFades = Array.from(gradients).filter((el) => @@ -446,7 +548,10 @@ describe('ContinuousView fade overlays', () => { }); it('renders prev fade after moving away from book start', async () => { - const { container } = render(); + const { container } = render( + , + withAnalysisStore, + ); await userEvent.click(screen.getByRole('button', { name: 'Next token' })); @@ -458,7 +563,10 @@ describe('ContinuousView fade overlays', () => { }); it('does not render next fade at book end', async () => { - const { container } = render(); + const { container } = render( + , + withAnalysisStore, + ); const nextBtn = screen.getByRole('button', { name: 'Next token' }); await userEvent.click(nextBtn); @@ -479,7 +587,7 @@ describe('ContinuousView fade overlays', () => { describe('ContinuousView cross-chapter traversal', () => { it('indexes tokens across chapter boundaries in segment order', () => { - render(); + render(, withAnalysisStore); // Both chapter tokens should be present expect(screen.getByText('Alpha')).toBeInTheDocument(); @@ -487,7 +595,7 @@ describe('ContinuousView cross-chapter traversal', () => { }); it('can navigate across a chapter boundary with the next arrow', async () => { - render(); + render(, withAnalysisStore); // Only one token per chapter, so clicking next once reaches chapter 2's token (index 1 = last) await userEvent.click(screen.getByRole('button', { name: 'Next token' })); @@ -505,7 +613,7 @@ describe('ContinuousView cross-chapter traversal', () => { describe('ContinuousView smooth-scroll intent', () => { it('calls scrollIntoView with smooth behaviour when next arrow is clicked', async () => { - render(); + render(, withAnalysisStore); await userEvent.click(screen.getByRole('button', { name: 'Next token' })); @@ -515,7 +623,7 @@ describe('ContinuousView smooth-scroll intent', () => { }); it('calls scrollIntoView with smooth behaviour when prev arrow is clicked', async () => { - render(); + render(, withAnalysisStore); await userEvent.click(screen.getByRole('button', { name: 'Next token' })); scrollIntoViewMock.mockClear(); @@ -528,7 +636,7 @@ describe('ContinuousView smooth-scroll intent', () => { }); it('does not call scrollIntoView when a disabled arrow is clicked', async () => { - render(); + render(, withAnalysisStore); scrollIntoViewMock.mockClear(); // Prev arrow is disabled at start — clicking it should be a no-op @@ -553,7 +661,12 @@ describe('ContinuousView activeVerse verse-jump', () => { it('positions at focusIndex 0 when activeVerse matches the first segment', () => { render( - , + , + withAnalysisStore, ); // At index 0 the prev arrow should be disabled @@ -563,11 +676,19 @@ describe('ContinuousView activeVerse verse-jump', () => { it('jumps to the first token of the second segment when activeVerse points there', () => { // makeBook() has 4 tokens: index 0,1 in segment GEN 1:1 and index 2,3 in GEN 1:2 const { rerender } = render( - , + , ); rerender( - , + , ); // Advance past the fade-out delay so the pending focus jump fires. act(() => { @@ -582,6 +703,7 @@ describe('ContinuousView activeVerse verse-jump', () => { const { rerender } = render( , ); @@ -589,6 +711,7 @@ describe('ContinuousView activeVerse verse-jump', () => { rerender( , ); @@ -605,12 +728,20 @@ describe('ContinuousView activeVerse verse-jump', () => { // External jumps use behavior:'auto' (not 'smooth') to avoid double-animation with the // strip opacity fade that already plays during the jump. const { rerender } = render( - , + , ); scrollIntoViewMock.mockClear(); rerender( - , + , ); act(() => { jest.advanceTimersByTime(500); @@ -621,13 +752,18 @@ describe('ContinuousView activeVerse verse-jump', () => { it('does not call onVerseChange when activeVerse changes', () => { const { rerender } = render( - , + , ); const handleVerseChange = jest.fn(); rerender( , @@ -643,7 +779,12 @@ describe('ContinuousView activeVerse verse-jump', () => { // makeBook(): GEN 1:1 at index 0-1, GEN 1:2 at index 2-3. Mounting with verse 2 should // start the strip focused at index 2 immediately (lazy useState initializer, no effect wait). render( - , + , + withAnalysisStore, ); // Index 2 is not the start (prev enabled) and not the end (next enabled). @@ -652,7 +793,7 @@ describe('ContinuousView activeVerse verse-jump', () => { }); it('does not jump when activeVerse is undefined', () => { - render(); + render(, withAnalysisStore); // Without activeVerse the strip stays at focusIndex 0 expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); @@ -660,7 +801,12 @@ describe('ContinuousView activeVerse verse-jump', () => { it('does not jump when activeVerse does not match any segment', () => { render( - , + , + withAnalysisStore, ); // No matching segment — strip stays at focusIndex 0 @@ -671,11 +817,19 @@ describe('ContinuousView activeVerse verse-jump', () => { // Start focused at GEN 1:1 (word token), then move activeVerse to GEN 1:2 (punctuation only). // getPhraseIndexForVerse should return undefined → no pending jump. const { rerender } = render( - , + , ); rerender( - , + , ); act(() => { jest.advanceTimersByTime(500); @@ -686,6 +840,104 @@ describe('ContinuousView activeVerse verse-jump', () => { }); }); +// --------------------------------------------------------------------------- +// activePhraseIndex direct jump +// --------------------------------------------------------------------------- + +describe('ContinuousView activePhraseIndex', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('jumps to the specified phrase index after the fade delay when activePhraseIndex changes', () => { + const book = makeBook(); + const { rerender } = render( + , + ); + + const phraseBtns = () => + screen.getAllByRole('button').filter((b) => b.dataset.phraseBox === 'true'); + + expect(phraseBtns()[0]).toHaveAttribute('data-focus-state', 'focused'); + + rerender(); + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(phraseBtns()[0]).toHaveAttribute('data-focus-state', 'default'); + expect(phraseBtns()[1]).toHaveAttribute('data-focus-state', 'focused'); + }); + + it('uses instant scrollIntoView behaviour after the fade completes', () => { + const book = makeBook(); + render( + , + withAnalysisStore, + ); + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(globalThis.HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'auto' }), + ); + }); + + it('remains hidden while a second click overrides a pending jump mid-fade', () => { + // Regression: when a second activePhraseIndex arrives before the first fade-out timer fires, + // the RAF cleanup from the first jump must not reveal the strip prematurely. + const book = makeBook(); + const { rerender } = render( + , + ); + act(() => { + jest.advanceTimersByTime(500); + }); + + // First click — strip fades out; timer is pending. + rerender(); + + // Second click before the first 500 ms timer fires — overrides the pending jump. + rerender(); + + // Strip should still be hidden while the second fade-out timer is pending. + expect(screen.getByTestId('token-strip')).toHaveClass('tw:opacity-0'); + }); + + it('jumps to the correct phrase when a second click arrives before the first jump resolves', () => { + // Regression: second click before the first fade timer fires must end at the second target, not + // wherever the first jump would have landed. + const book = makeBook(); + const { rerender } = render( + , + ); + act(() => { + jest.advanceTimersByTime(500); + }); + + const phraseBtns = () => + screen.getAllByRole('button').filter((b) => b.dataset.phraseBox === 'true'); + + // First click to index 1. + rerender(); + + // Second click back to index 0 before the first timer fires. + rerender(); + + // Let the second fade-out timer fire. + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(phraseBtns()[0]).toHaveAttribute('data-focus-state', 'focused'); + expect(phraseBtns()[1]).toHaveAttribute('data-focus-state', 'default'); + }); +}); + // --------------------------------------------------------------------------- // onVerseChange outbound propagation // --------------------------------------------------------------------------- @@ -694,7 +946,10 @@ describe('ContinuousView onVerseChange propagation', () => { it('calls onVerseChange when the next arrow crosses into a new verse', async () => { // makeBook(): segment GEN 1:1 has tokens at index 0,1; GEN 1:2 starts at index 2 const handleVerseChange = jest.fn(); - render(); + render( + , + withAnalysisStore, + ); // Advance twice to reach index 2 (first token of GEN 1:2) await userEvent.click(screen.getByRole('button', { name: 'Next token' })); @@ -708,9 +963,11 @@ describe('ContinuousView onVerseChange propagation', () => { render( , + withAnalysisStore, ); handleVerseChange.mockClear(); @@ -722,7 +979,10 @@ describe('ContinuousView onVerseChange propagation', () => { it('does not call onVerseChange for multiple arrow clicks within the same verse', async () => { const handleVerseChange = jest.fn(); - render(); + render( + , + withAnalysisStore, + ); handleVerseChange.mockClear(); // index 0 → index 1: both are in GEN 1:1, no verse change @@ -733,7 +993,14 @@ describe('ContinuousView onVerseChange propagation', () => { it('calls onVerseChange with the chapter-2 verse when crossing the chapter boundary', async () => { const handleVerseChange = jest.fn(); - render(); + render( + , + withAnalysisStore, + ); handleVerseChange.mockClear(); // ch1 has 1 token (index 0), ch2 starts at index 1 — one click crosses the boundary @@ -745,7 +1012,7 @@ describe('ContinuousView onVerseChange propagation', () => { it('does not call onVerseChange when book changes and focus resets to the first phrase', async () => { const handleVerseChange = jest.fn(); const { rerender } = render( - , + , ); // Move focus away from index 0 so book-switch reset path is exercised. @@ -763,7 +1030,9 @@ describe('ContinuousView onVerseChange propagation', () => { })), }; - rerender(); + rerender( + , + ); expect(handleVerseChange).not.toHaveBeenCalled(); }); @@ -783,16 +1052,52 @@ describe('ContinuousView RTL layout', () => { }); it('shows right-arrow (→) on the previous button in RTL mode', () => { - render(); + render(, withAnalysisStore); const prevBtn = screen.getByRole('button', { name: 'Previous token' }); expect(prevBtn.querySelector('[aria-hidden="true"]')).toHaveTextContent('\u2192'); }); it('shows left-arrow (←) on the next button in RTL mode', () => { - render(); + render(, withAnalysisStore); const nextBtn = screen.getByRole('button', { name: 'Next token' }); expect(nextBtn.querySelector('[aria-hidden="true"]')).toHaveTextContent('\u2190'); }); }); + +// --------------------------------------------------------------------------- +// Phrase window — windowing branches (PHRASE_WINDOW_HALF = 100 on each side) +// --------------------------------------------------------------------------- + +describe('ContinuousView phrase window', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('activates both windowing branches when the focused phrase is deep inside a large book', () => { + // A book with 250 tokens: focusing phrase 125 means windowStart = 25 (> 0) and + // windowEnd = 225 (< 249), exercising both the windowStartTokenIndex and + // windowEndTokenIndex non-default branches. + const book = makeLargeBook(250); + render( + , + withAnalysisStore, + ); + act(() => { + jest.advanceTimersByTime(500); + }); + + // Phrase 125 is not at the start or end, so both arrows are enabled. + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Next token' })).toBeEnabled(); + }); +}); diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index 74a53c34..386fe987 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -3,68 +3,140 @@ /// import type { SerializedVerseRef } from '@sillsdev/scripture'; -import { render, screen } from '@testing-library/react'; -import type { Book, Segment } from 'interlinearizer'; +import { act, render, screen } from '@testing-library/react'; +import type { Book, ScriptureRef, Segment } from 'interlinearizer'; +import type { ReactNode } from 'react'; import Interlinearizer from '../../components/Interlinearizer'; +import type { SegmentDisplayMode } from '../../components/SegmentView'; +import { defaultScrRef, GEN_1_1_BOOK } from '../test-helpers'; -// Store captured props so tests can inspect what Interlinearizer passes down -let capturedContinuousViewProps: Record = {}; +jest.mock('lucide-react', () => ({ + __esModule: true, + /** + * Stub for the LocateFixed icon; renders a minimal SVG so icon-presence assertions work. + * + * @returns An SVG element with `data-testid="locate-fixed-icon"`. + */ + LocateFixed: () => , +})); + +/** + * Props captured from ContinuousView renders so tests can assert on what Interlinearizer passes + * down. + */ +type CapturedContinuousViewProps = { + /** When set, the strip jumps to this phrase index. */ + activePhraseIndex: number | undefined; + /** Verse coordinate used to scroll the strip. */ + activeVerse: ScriptureRef; + /** The full tokenized book. */ + book: Book; + /** Called when the focused phrase index changes. */ + onFocusPhraseIndexChange: (index: number) => void; + /** Called when arrow navigation moves focus into a new verse. */ + onVerseChange: (verse: ScriptureRef) => void; +}; +let capturedContinuousViewProps: CapturedContinuousViewProps | undefined; +/** Props captured from SegmentView renders so tests can assert on what Interlinearizer passes down. */ type CapturedSegmentViewProps = { + /** The segment the component is asked to render. */ segment: Segment; - displayMode?: string; - isActive?: boolean; - onClick?: (ref: { book: string; chapter: number; verse: number }) => void; + /** Controls whether tokens are rendered as chips or as raw baseline text. */ + displayMode: SegmentDisplayMode; + /** The `Token.ref` string of the currently focused token, if any. */ + focusedTokenRef: string | undefined; + /** Whether this segment corresponds to the currently active verse. */ + isActive: boolean; + /** Called when the user selects a token. */ + onSelect: (ref: ScriptureRef, tokenRef?: string) => void; }; let capturedSegmentViewPropsList: CapturedSegmentViewProps[] = []; +jest.mock('../../components/AnalysisStore', () => ({ + __esModule: true, + /** + * Pass-through provider stub that renders children directly, keeping AnalysisStore.tsx out of + * scope. + * + * @param props - Component props. + * @param props.children - Child nodes to render. + * @returns The children unchanged. + */ + AnalysisStoreProvider({ children }: Readonly<{ children: ReactNode }>) { + return children; + }, + /** + * Returns a fixed empty gloss string for any token. + * + * @returns An empty string. + */ + useGloss: () => '', + /** + * Returns a no-op dispatch function. + * + * @returns A function that accepts any arguments and does nothing. + */ + useGlossDispatch: () => () => {}, +})); + jest.mock('../../components/ContinuousView', () => ({ __esModule: true, - default: (props: Record) => { + default: (props: CapturedContinuousViewProps) => { capturedContinuousViewProps = props; - return
; + return ( +
+ ); }, })); jest.mock('../../components/SegmentView', () => ({ __esModule: true, - SegmentView: ({ segment, ...rest }: CapturedSegmentViewProps) => { - capturedSegmentViewPropsList.push({ segment, ...rest }); - return
; + /** + * Named export stub for SegmentView; captures received props and renders a minimal div. + * + * @param props - The props passed by Interlinearizer. + * @param props.segment - The segment being rendered. + * @param props.isActive - Whether this segment is the active verse. + * @param props.rest - Any additional props forwarded from the parent. + * @returns A div with `data-testid="segment-view"` and the segment id. + */ + SegmentView: ({ segment, isActive, ...rest }: CapturedSegmentViewProps) => { + capturedSegmentViewPropsList.push({ segment, isActive, ...rest }); + return ( +
+ ); }, - default: ({ segment, ...rest }: CapturedSegmentViewProps) => { - capturedSegmentViewPropsList.push({ segment, ...rest }); - return
; + /** + * Default export stub for SegmentView; captures received props and renders a minimal div. + * + * @param props - The props passed by Interlinearizer. + * @param props.segment - The segment being rendered. + * @param props.isActive - Whether this segment is the active verse. + * @param props.rest - Any additional props forwarded from the parent. + * @returns A div with `data-testid="segment-view"` and the segment id. + */ + default: ({ segment, isActive, ...rest }: CapturedSegmentViewProps) => { + capturedSegmentViewPropsList.push({ segment, isActive, ...rest }); + return ( +
+ ); }, })); -const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; - -/** Pre-built Book with one GEN 1:1 segment. */ -const GEN_1_1_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: 'In the beginning.', - tokens: [ - { - ref: 'GEN 1:1:0', - surfaceText: 'In', - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 2, - }, - ], - }, - ], -}; - /** Pre-built Book with no segments — used by the no-verse-data test. */ const GEN_EMPTY_BOOK: Book = { id: 'GEN', bookRef: 'GEN', textVersion: 'v1', segments: [] }; @@ -118,13 +190,13 @@ const GEN_1_MULTI_BOOK: Book = { */ function renderInterlinearizer({ book = GEN_1_1_BOOK, - bookSegments = GEN_1_1_BOOK.segments, + chapterSegments = GEN_1_1_BOOK.segments, continuousScroll = false, scrRef = defaultScrRef, setScrRef = () => {}, }: { book?: Book; - bookSegments?: Book['segments']; + chapterSegments?: Book['segments']; continuousScroll?: boolean; scrRef?: SerializedVerseRef; setScrRef?: (r: SerializedVerseRef) => void; @@ -132,17 +204,23 @@ function renderInterlinearizer({ return render( , ); } +beforeEach(() => { + // jsdom does not implement scrollIntoView; stub it globally so components that call it don't throw. + Element.prototype.scrollIntoView = jest.fn(); +}); + describe('Interlinearizer', () => { beforeEach(() => { - capturedContinuousViewProps = {}; + capturedContinuousViewProps = undefined; capturedSegmentViewPropsList = []; }); @@ -153,13 +231,13 @@ describe('Interlinearizer', () => { }); it('shows a no-verse message when the tokenized book has no segments at all', () => { - renderInterlinearizer({ bookSegments: GEN_EMPTY_BOOK.segments }); + renderInterlinearizer({ chapterSegments: GEN_EMPTY_BOOK.segments }); expect(screen.getByText(/no verse data for gen 1\./i)).toBeInTheDocument(); }); it('renders a SegmentView for every segment in the current chapter', () => { - renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments }); + renderInterlinearizer({ chapterSegments: GEN_1_MULTI_BOOK.segments }); expect(screen.getAllByTestId('segment-view')).toHaveLength(2); expect(capturedSegmentViewPropsList[0].segment.id).toBe('GEN 1:1'); @@ -167,7 +245,7 @@ describe('Interlinearizer', () => { }); it('passes isActive=true only to the segment matching the current verse', () => { - renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments }); + renderInterlinearizer({ chapterSegments: GEN_1_MULTI_BOOK.segments }); // defaultScrRef is GEN 1:1 expect(capturedSegmentViewPropsList[0].isActive).toBe(true); @@ -176,28 +254,22 @@ describe('Interlinearizer', () => { 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 }); + renderInterlinearizer({ chapterSegments: GEN_1_MULTI_BOOK.segments, scrRef: titleRef }); expect(screen.getAllByTestId('segment-view')).toHaveLength(2); }); - it('calls setScrRef with the segment ref when a verse box is clicked', () => { + it('calls setScrRef with the segment ref when a segment fires onSelect', () => { const mockSetScrRef = jest.fn(); - renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, setScrRef: mockSetScrRef }); + renderInterlinearizer({ chapterSegments: GEN_1_MULTI_BOOK.segments, setScrRef: mockSetScrRef }); - capturedSegmentViewPropsList[1].onClick?.({ book: 'GEN', chapter: 1, verse: 2 }); + capturedSegmentViewPropsList[1].onSelect?.({ book: 'GEN', chapter: 1, verse: 2 }); expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); }); - it('passes displayMode="baseline-text" to SegmentView when continuousScroll is true', () => { - renderInterlinearizer({ continuousScroll: true }); - - expect(capturedSegmentViewPropsList[0].displayMode).toBe('baseline-text'); - }); - it('passes displayMode="baseline-text" to all SegmentViews when continuousScroll is true', () => { - renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true }); + renderInterlinearizer({ chapterSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true }); capturedSegmentViewPropsList.forEach((p) => expect(p.displayMode).toBe('baseline-text')); }); @@ -216,7 +288,7 @@ describe('Interlinearizer', () => { it('renders ContinuousView above the chapter segment rows when both are present', () => { const { container } = renderInterlinearizer({ - bookSegments: GEN_1_MULTI_BOOK.segments, + chapterSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true, }); @@ -227,18 +299,264 @@ describe('Interlinearizer', () => { expect(allElements[0]).toBe(continuousView); }); + it('calls setScrRef with the segment ref when a token is clicked', () => { + const mockSetScrRef = jest.fn(); + renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + chapterSegments: GEN_1_MULTI_BOOK.segments, + setScrRef: mockSetScrRef, + }); + + act(() => { + capturedSegmentViewPropsList[1].onSelect?.( + { book: 'GEN', chapter: 1, verse: 2 }, + 'GEN 1:2:0', + ); + }); + + expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); + }); + + it('passes activePhraseIndex to ContinuousView matching the clicked token', () => { + // Render in token-chip mode first so onSelect is available on SegmentView props. + const { rerender } = renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + chapterSegments: GEN_1_MULTI_BOOK.segments, + continuousScroll: false, + }); + + // GEN 1:2 word token is phrase index 1 (after GEN 1:1's one word token at index 0). + const { onSelect } = capturedSegmentViewPropsList[1]; + if (typeof onSelect !== 'function') throw new Error('Expected onSelect to be a function'); + + act(() => { + onSelect({ book: 'GEN', chapter: 1, verse: 2 }, 'GEN 1:2:0'); + }); + + // Switch to continuous-scroll mode so ContinuousView is rendered and its props captured. + capturedSegmentViewPropsList = []; + rerender( + {}} + analysisLanguage="und" + />, + ); + + if (!capturedContinuousViewProps) + throw new Error('Expected ContinuousView to have been rendered'); + expect(capturedContinuousViewProps.activePhraseIndex).toBe(1); + }); + it('calls setScrRef when ContinuousView emits onVerseChange', () => { const mockSetScrRef = jest.fn(); renderInterlinearizer({ continuousScroll: true, setScrRef: mockSetScrRef }); expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); + if (!capturedContinuousViewProps) + throw new Error('Expected ContinuousView to have been rendered'); const { onVerseChange } = capturedContinuousViewProps; - if (typeof onVerseChange !== 'function') - throw new Error('Expected onVerseChange to be a function'); onVerseChange({ book: 'GEN', chapter: 2, verse: 3 }); expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 2, verseNum: 3 }); }); + + it('does not update activePhraseIndex when ContinuousView emits onFocusPhraseIndexChange', () => { + const { rerender } = renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + chapterSegments: GEN_1_MULTI_BOOK.segments, + continuousScroll: true, + }); + + if (!capturedContinuousViewProps) + throw new Error('Expected ContinuousView to have been rendered'); + const { onFocusPhraseIndexChange } = capturedContinuousViewProps; + + act(() => { + onFocusPhraseIndexChange(1); + }); + + // Re-render in continuous mode — just verifying the callback does not throw and updates state. + capturedSegmentViewPropsList = []; + rerender( + {}} + analysisLanguage="und" + />, + ); + + if (!capturedContinuousViewProps) + throw new Error('Expected ContinuousView to have been rendered'); + expect(capturedContinuousViewProps.activePhraseIndex).toBeUndefined(); + }); + + it('carries the strip phrase position into segment view when switching off continuousScroll', () => { + const { rerender } = renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + chapterSegments: GEN_1_MULTI_BOOK.segments, + continuousScroll: true, + }); + + // Simulate ContinuousView reporting that phrase index 1 (GEN 1:2's token) is in view. + if (!capturedContinuousViewProps) + throw new Error('Expected ContinuousView to have been rendered'); + const { onFocusPhraseIndexChange } = capturedContinuousViewProps; + + act(() => { + onFocusPhraseIndexChange(1); + }); + + // Switch to segment view — Interlinearizer should carry over phrase index 1 as the focus. + capturedSegmentViewPropsList = []; + rerender( + {}} + analysisLanguage="und" + />, + ); + + // The token at phrase index 1 is 'GEN 1:2:0'; it should now be the focusedTokenRef. + const focused = capturedSegmentViewPropsList.find((p) => p.focusedTokenRef === 'GEN 1:2:0'); + expect(focused).toBeDefined(); + }); + + it('falls back to the active-verse first word when switching off continuousScroll with no strip position', () => { + // Start in continuous mode without ContinuousView ever calling onFocusPhraseIndexChange. + const { rerender } = renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + chapterSegments: GEN_1_MULTI_BOOK.segments, + continuousScroll: true, + scrRef: { book: 'GEN', chapterNum: 1, verseNum: 1 }, + }); + + // Switch to segment view without any strip position having been reported. + capturedSegmentViewPropsList = []; + rerender( + {}} + analysisLanguage="und" + />, + ); + + // The fallback focuses the first word of GEN 1:1 ('GEN 1:1:0'). + const focused = capturedSegmentViewPropsList.find((p) => p.focusedTokenRef === 'GEN 1:1:0'); + expect(focused).toBeDefined(); + }); + + it('preserves an existing focusedTokenRef when switching off continuousScroll with no strip position', () => { + // Start in segment mode and focus a specific token. + const { rerender } = renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + chapterSegments: GEN_1_MULTI_BOOK.segments, + continuousScroll: false, + }); + + // Click a token to set focusedTokenRef to 'GEN 1:2:0'. + const { onSelect } = capturedSegmentViewPropsList[1]; + if (typeof onSelect !== 'function') throw new Error('Expected onSelect to be a function'); + act(() => { + onSelect({ book: 'GEN', chapter: 1, verse: 2 }, 'GEN 1:2:0'); + }); + + // Switch to continuous mode (without strip reporting any position). + capturedSegmentViewPropsList = []; + rerender( + {}} + analysisLanguage="und" + />, + ); + + // Switch back to segment mode — existing focusedTokenRef should be preserved. + capturedSegmentViewPropsList = []; + rerender( + {}} + analysisLanguage="und" + />, + ); + + // 'GEN 1:2:0' was already focused, so the fallback must not overwrite it. + const stillFocused = capturedSegmentViewPropsList.find( + (p) => p.focusedTokenRef === 'GEN 1:2:0', + ); + expect(stillFocused).toBeDefined(); + }); + + it('renders the snap-to-active-verse button when segments are present', () => { + renderInterlinearizer({ chapterSegments: GEN_1_MULTI_BOOK.segments }); + + expect(screen.getByRole('button', { name: /scroll to active verse/i })).toBeInTheDocument(); + }); + + it('does not render the snap-to-active-verse button when there are no segments', () => { + renderInterlinearizer({ chapterSegments: GEN_EMPTY_BOOK.segments }); + + expect( + screen.queryByRole('button', { name: /scroll to active verse/i }), + ).not.toBeInTheDocument(); + }); + + it('snap button calls scrollIntoView on the active segment', () => { + renderInterlinearizer({ chapterSegments: GEN_1_1_BOOK.segments }); + + act(() => { + screen.getByRole('button', { name: /scroll to active verse/i }).click(); + }); + + expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'auto', + block: 'start', + }); + }); + + it('leaves focusedTokenRef undefined when switching off continuousScroll with no strip position and no matching segment', () => { + // scrRef points to verse 99 which does not exist in GEN_1_MULTI_BOOK. + const { rerender } = renderInterlinearizer({ + book: GEN_1_MULTI_BOOK, + chapterSegments: GEN_1_MULTI_BOOK.segments, + continuousScroll: true, + scrRef: { book: 'GEN', chapterNum: 1, verseNum: 99 }, + }); + + capturedSegmentViewPropsList = []; + rerender( + {}} + analysisLanguage="und" + />, + ); + + // No segment matches verse 99 so focusedTokenRef stays undefined for all views. + capturedSegmentViewPropsList.forEach((p) => expect(p.focusedTokenRef).toBeUndefined()); + }); }); diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index b0400868..109cefd5 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -6,11 +6,11 @@ import { useData, useLocalizedStrings, useSetting } 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 type { Book, Segment } from 'interlinearizer'; import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData'; import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting'; import InterlinearizerLoader from '../../components/InterlinearizerLoader'; -import { makeWebViewState } from '../test-helpers'; +import { defaultScrRef, GEN_1_1_BOOK, makeWebViewState } from '../test-helpers'; jest.mock('../../hooks/useInterlinearizerBookData'); jest.mock('../../hooks/useOptimisticBooleanSetting'); @@ -48,7 +48,12 @@ jest.mock('../../components/ContinuousView', () => ({ })); type CapturedInterlinearizerProps = { + book: Book; + chapterSegments: Segment[]; continuousScroll: boolean; + scrRef: SerializedVerseRef; + setScrRef: (newScrRef: SerializedVerseRef) => void; + analysisLanguage: string; }; let capturedInterlinearizerProps: CapturedInterlinearizerProps | undefined; @@ -194,33 +199,6 @@ jest.mock('../../components/ProjectModals', () => ({ }, })); -const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; - -/** Pre-built Book with one GEN 1:1 segment. */ -const GEN_1_1_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: 'In the beginning.', - tokens: [ - { - ref: 'GEN 1:1:0', - surfaceText: 'In', - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 2, - }, - ], - }, - ], -}; - /** Returns a `useWebViewScrollGroupScrRef` hook stub fixed to GEN 1:1. */ function makeScrollGroupHook() { return (): [ @@ -271,6 +249,27 @@ function mockOptimisticSetting( return onChange; } +/** + * Configures `useSetting` to return per-key values for the two settings consumed by + * `InterlinearizerLoader`: `platform.interfaceMode` and `platform.interfaceLanguage`. + * + * @param interfaceMode - Value for `platform.interfaceMode`; defaults to `'simple'`. + * @param interfaceLanguage - Value for `platform.interfaceLanguage`; defaults to `[]`. + * @throws {Error} When `useSetting` is called with any key other than `platform.interfaceMode` or + * `platform.interfaceLanguage` (message: `useSetting mock: unexpected key ""`). + */ +function mockSettings( + interfaceMode: 'simple' | 'power' = 'simple', + interfaceLanguage: string[] = [], +): void { + jest.mocked(useSetting).mockImplementation((key: string) => { + if (key === 'platform.interfaceMode') return [interfaceMode, jest.fn(), jest.fn(), false]; + if (key === 'platform.interfaceLanguage') + return [interfaceLanguage, jest.fn(), jest.fn(), false]; + throw new Error(`useSetting mock: unexpected key "${key}"`); + }); +} + describe('InterlinearizerLoader', () => { beforeEach(() => { capturedInterlinearizerProps = undefined; @@ -282,11 +281,11 @@ describe('InterlinearizerLoader', () => { new Proxy({}, { get: () => jest.fn().mockReturnValue([undefined, jest.fn(), false]) }), ); jest.mocked(useLocalizedStrings).mockReturnValue([{}, false]); - jest.mocked(useSetting).mockReturnValue(['simple', jest.fn(), jest.fn(), false]); + mockSettings(); }); it('shows nav controls when interface mode is power', () => { - jest.mocked(useSetting).mockReturnValue(['power', jest.fn(), jest.fn(), false]); + mockSettings('power'); render( { expect(screen.getByTestId('interlinearizer')).toBeInTheDocument(); }); + it('passes the first interfaceLanguage tag to Interlinearizer as analysisLanguage', () => { + mockSettings('simple', ['fr', 'en']); + render( + , + ); + + expect(capturedInterlinearizerProps?.analysisLanguage).toBe('fr'); + }); + + it('passes "und" to Interlinearizer as analysisLanguage when interfaceLanguage is empty', () => { + render( + , + ); + + expect(capturedInterlinearizerProps?.analysisLanguage).toBe('und'); + }); + describe('modal interactions', () => { it('opens the select modal when the project menu selectProject item is clicked', async () => { render( diff --git a/src/__tests__/components/PhraseBox.test.tsx b/src/__tests__/components/PhraseBox.test.tsx index 375fde55..050ab84c 100644 --- a/src/__tests__/components/PhraseBox.test.tsx +++ b/src/__tests__/components/PhraseBox.test.tsx @@ -4,131 +4,301 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { Token } from 'interlinearizer'; +import type { AssignmentStatus, Token } from 'interlinearizer'; +import type { ReactNode } from 'react'; +import { AnalysisStoreProvider } from '../../components/AnalysisStore'; import { PhraseBox } from '../../components/PhraseBox'; -jest.mock('../../components/TokenChip', () => ({ - __esModule: true, - default: ({ token }: { token: Token }) => ( - {token.surfaceText} - ), -})); +// --------------------------------------------------------------------------- +// AnalysisStore mock — reactive useState-based stub so AnalysisStore.tsx stays out of scope +// --------------------------------------------------------------------------- + +jest.mock('../../components/AnalysisStore', () => { + const { createContext, useCallback, useContext, useMemo, useState } = + jest.requireActual('react'); + + type GlossMap = Record; + type MockCtxValue = { + glosses: GlossMap; + dispatch: (tokenRef: string, surfaceText: string, value: string) => void; + }; + const MockCtx = createContext({ glosses: {}, dispatch: () => {} }); + + return { + __esModule: true, + AnalysisStoreProvider({ + children, + initialAnalysis, + analysisLanguage, + onGlossChange, + }: Readonly<{ + children: ReactNode; + initialAnalysis?: { + tokenAnalyses: { id: string; gloss?: GlossMap }[]; + tokenAnalysisLinks: { + analysisId: string; + status: AssignmentStatus; + token: { tokenRef: string }; + }[]; + }; + analysisLanguage: string; + onGlossChange?: (tokenRef: string, value: string) => void; + }>) { + const byId = new Map((initialAnalysis?.tokenAnalyses ?? []).map((ta) => [ta.id, ta])); + const seed: GlossMap = (initialAnalysis?.tokenAnalysisLinks ?? []) + .filter((link) => link.status === 'approved') + .reduce((acc, link) => { + const gloss = byId.get(link.analysisId)?.gloss?.[analysisLanguage]; + return gloss === undefined ? acc : { ...acc, [link.token.tokenRef]: gloss }; + }, {}); + const [glosses, setGlosses] = useState(seed); + const dispatch = useCallback( + (tokenRef: string, _surfaceText: string, value: string) => { + setGlosses((prev) => ({ ...prev, [tokenRef]: value })); + onGlossChange?.(tokenRef, value); + }, + [onGlossChange], + ); + const ctx = useMemo(() => ({ glosses, dispatch }), [glosses, dispatch]); + return {children}; + }, + useGloss(tokenRef: string) { + return useContext(MockCtx).glosses[tokenRef] ?? ''; + }, + useGlossDispatch() { + return useContext(MockCtx).dispatch; + }, + }; +}); + +jest.mock('../../components/TokenChip', () => { + const { useGloss, useGlossDispatch } = jest.requireMock< + typeof import('../../components/AnalysisStore') + >('../../components/AnalysisStore'); + function MockTokenChip({ onFocus, token }: Readonly<{ onFocus?: () => void; token: Token }>) { + const gloss = useGloss(token.ref); + const dispatch = useGlossDispatch(); + return ( + + {token.surfaceText} + dispatch(token.ref, token.surfaceText, e.target.value)} + onFocus={onFocus} + value={gloss} + /> + + ); + } + return { __esModule: true, default: MockTokenChip }; +}); /** Pre-built test token */ -const TEST_TOKEN: Token = { +const TEST_TOKEN = { ref: 'token-1', surfaceText: 'Hello', writingSystem: 'en', type: 'word', charStart: 0, charEnd: 5, -}; +} satisfies Token; /** Second test token */ -const TEST_TOKEN_2: Token = { +const TEST_TOKEN_2 = { ref: 'token-2', surfaceText: 'World', writingSystem: 'en', type: 'word', charStart: 6, charEnd: 11, -}; +} satisfies Token; -describe('PhraseBox', () => { - it('renders as a span when no onClick handler is provided', () => { - render(); +/** Shared props shape used by both helper functions. */ +type PhraseBoxTestProps = { + index: number | undefined; + isFocused: boolean; + onFocusPhrase: (index?: number) => void; + tokens: (Token & { type: 'word' })[]; +}; - const phraseBox = document.querySelector('[data-phrase-box="true"]'); - expect(phraseBox?.tagName).toBe('SPAN'); - }); +/** + * Minimal required props for PhraseBox. Spread into render calls so tests only need to override + * what they actually care about. + * + * @returns An object containing all required PhraseBox props set to no-op stubs. + */ +function requiredProps(): PhraseBoxTestProps { + return { + index: undefined, + isFocused: false, + onFocusPhrase: jest.fn(), + tokens: [TEST_TOKEN], + }; +} - it('renders as a button when onClick handler is provided', () => { - const mockOnClick = jest.fn(); - render(); +describe('PhraseBox', () => { + it('renders as a label', () => { + render( + + + , + ); const phraseBox = document.querySelector('[data-phrase-box="true"]'); - expect(phraseBox?.tagName).toBe('BUTTON'); - expect(phraseBox).toHaveAttribute('type', 'button'); + expect(phraseBox?.tagName).toBe('LABEL'); }); - it('renders tokens using TokenChip components', () => { - render(); + it('renders one TokenChip per token in the tokens array', () => { + render( + + + , + ); expect(screen.getByTestId('token-token-1')).toBeInTheDocument(); expect(screen.getByTestId('token-token-2')).toBeInTheDocument(); }); - it('calls onClick when button is clicked', async () => { - const mockOnClick = jest.fn(); - render(); + it('clicking the outer container focuses the first gloss input', async () => { + render( + + + , + ); - const button = screen.getByRole('button'); - await userEvent.click(button); + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + await userEvent.click(phraseBox ?? document.body); - expect(mockOnClick).toHaveBeenCalledTimes(1); + expect(screen.getByRole('textbox', { name: 'Gloss for Hello' })).toHaveFocus(); }); - it('applies focused styling when isFocused is true', () => { - render(); + it('applies focused border and background when isFocused is true', () => { + render( + + + , + ); const phraseBox = document.querySelector('[data-phrase-box="true"]'); expect(phraseBox).toHaveAttribute('data-focus-state', 'focused'); expect(phraseBox).toHaveClass('tw:border-2'); + expect(phraseBox).toHaveClass('tw:border-white'); + expect(phraseBox).toHaveClass('tw:bg-muted/30'); }); - it('applies default styling when isFocused is false', () => { - render(); + it('applies default border and background when isFocused is false', () => { + render( + + + , + ); const phraseBox = document.querySelector('[data-phrase-box="true"]'); expect(phraseBox).toHaveAttribute('data-focus-state', 'default'); - expect(phraseBox).not.toHaveClass('tw:border-2'); + expect(phraseBox).toHaveClass('tw:border'); + expect(phraseBox).toHaveClass('tw:border-border/40'); + expect(phraseBox).toHaveClass('tw:bg-muted/20'); }); - it('applies default styling when isFocused is not provided', () => { - render(); + it('phrase box does not override cursor on gap areas', () => { + render( + + + , + ); const phraseBox = document.querySelector('[data-phrase-box="true"]'); - expect(phraseBox).toHaveAttribute('data-focus-state', 'default'); + expect(phraseBox).not.toHaveClass('tw:cursor-text'); }); - it('button has correct focused styling and cursor', () => { - const mockOnClick = jest.fn(); - render(); + it('renders tokens in the order they appear in the tokens array', () => { + render( + + + , + ); - const button = screen.getByRole('button'); - expect(button).toHaveAttribute('data-focus-state', 'focused'); - expect(button).toHaveClass('tw:cursor-pointer'); - expect(button).toHaveClass('tw:text-left'); + const tokens = document.querySelectorAll('[data-testid^="token-"]'); + expect(tokens[0]).toHaveAttribute('data-testid', 'token-token-1'); + expect(tokens[1]).toHaveAttribute('data-testid', 'token-token-2'); }); - it('button has hover styling', () => { - const mockOnClick = jest.fn(); - render(); + it('passes the gloss for each token from the store', () => { + const initialAnalysis = { + segmentAnalyses: [], + segmentAnalysisLinks: [], + tokenAnalyses: [ + { id: 'ta-1', surfaceText: 'Hello', gloss: { und: 'hello' } }, + { id: 'ta-2', surfaceText: 'World', gloss: { und: 'world' } }, + ], + tokenAnalysisLinks: [ + { + analysisId: 'ta-1', + status: 'approved', + token: { tokenRef: 'token-1', surfaceText: 'Hello' }, + } satisfies { + analysisId: string; + status: AssignmentStatus; + token: { tokenRef: string; surfaceText: string }; + }, + { + analysisId: 'ta-2', + status: 'approved', + token: { tokenRef: 'token-2', surfaceText: 'World' }, + } satisfies { + analysisId: string; + status: AssignmentStatus; + token: { tokenRef: string; surfaceText: string }; + }, + ], + phraseAnalyses: [], + phraseAnalysisLinks: [], + }; + render( + + + , + ); - const button = screen.getByRole('button'); - expect(button).toHaveClass('tw:hover:bg-muted/30'); + expect(screen.getByRole('textbox', { name: 'Gloss for Hello' })).toHaveValue('hello'); + expect(screen.getByRole('textbox', { name: 'Gloss for World' })).toHaveValue('world'); }); - it('renders multiple tokens in order', () => { - render(); + it('shows an empty string when the token id is absent from the store', () => { + render( + + + , + ); - const tokens = document.querySelectorAll('[data-testid^="token-"]'); - expect(tokens[0]).toHaveAttribute('data-testid', 'token-token-1'); - expect(tokens[1]).toHaveAttribute('data-testid', 'token-token-2'); + expect(screen.getByRole('textbox', { name: 'Gloss for Hello' })).toHaveValue(''); }); - it('applies base spacing classes to both button and span', () => { - const { rerender } = render(); + it('updates the store when a gloss input changes', async () => { + const spy = jest.fn(); + render( + + + , + ); + + await userEvent.type(screen.getByRole('textbox', { name: 'Gloss for Hello' }), 'hi'); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenNthCalledWith(1, 'token-1', 'h'); + expect(spy).toHaveBeenNthCalledWith(2, 'token-1', 'hi'); + }); - const span = document.querySelector('[data-phrase-box="true"]'); - expect(span).toHaveClass('tw:px-1'); - expect(span).toHaveClass('tw:py-0.5'); + it('calls onFocusPhrase with index when a gloss input receives focus', async () => { + const handleFocus = jest.fn(); + render( + + + , + ); - const mockOnClick = jest.fn(); - rerender(); + await userEvent.click(screen.getByRole('textbox', { name: 'Gloss for Hello' })); - const button = screen.getByRole('button'); - expect(button).toHaveClass('tw:px-1'); - expect(button).toHaveClass('tw:py-0.5'); + expect(handleFocus).toHaveBeenCalledWith(2); }); }); diff --git a/src/__tests__/components/ProjectModals.test.tsx b/src/__tests__/components/ProjectModals.test.tsx index 12cfbbdf..93fceebf 100644 --- a/src/__tests__/components/ProjectModals.test.tsx +++ b/src/__tests__/components/ProjectModals.test.tsx @@ -101,12 +101,12 @@ jest.mock('../../components/ProjectMetadataModal', () => ({ onProjectDeleted, }: { onClose: () => void; - onProjectSaved: (u: { + onProjectSaved?: (u: { name?: string; description?: string; analysisLanguages: string[]; }) => void; - onProjectDeleted: (id: string) => void; + onProjectDeleted?: (id: string) => void; }) => (
+ + ))} + + ), })); /** A word token segment. */ @@ -64,70 +98,146 @@ const PUNCT_SEGMENT: Segment = { ], }; +/** + * Minimal required props for SegmentView. Spread into render calls so tests only need to override + * what they actually care about. + * + * @returns An object containing all required SegmentView props set to no-op stubs. + */ +function requiredProps(): { + displayMode: 'token-chip'; + focusedTokenRef: string | undefined; + isActive: boolean; + onSelect: (ref: ScriptureRef, tokenRef?: string) => void; + segment: Segment; +} { + return { + displayMode: 'token-chip', + focusedTokenRef: undefined, + isActive: false, + onSelect: jest.fn(), + segment: WORD_SEGMENT, + }; +} + describe('SegmentView', () => { it('renders word token chips in token-chip mode (default)', () => { - render(); + render( + + + , + ); expect(screen.getByText('In')).toBeInTheDocument(); expect(screen.getByText('the')).toBeInTheDocument(); }); it('renders non-word (punctuation) tokens in token-chip mode', () => { - render(); + render( + + + , + ); expect(screen.getByText('.')).toBeInTheDocument(); }); it('renders baselineText in baseline-text mode', () => { - render(); + render( + + + , + ); expect(screen.getByText('In the beginning.')).toBeInTheDocument(); }); it('does not render individual tokens in baseline-text mode', () => { - render(); + render( + + + , + ); expect(screen.queryByText('In')).not.toBeInTheDocument(); expect(screen.queryByText('the')).not.toBeInTheDocument(); }); it('shows the verse number label', () => { - render(); + render( + + + , + ); expect(screen.getByText('1')).toBeInTheDocument(); }); it('sets aria-current="true" when isActive is true', () => { - render(); + const { container } = render( + + + , + ); - expect(screen.getByRole('button')).toHaveAttribute('aria-current', 'true'); + expect(container.firstChild).toHaveAttribute('aria-current', 'true'); }); - it('does not set aria-current when isActive is false', () => { - render(); + it('does not set aria-current when isActive is omitted', () => { + const { container } = render( + + + , + ); - expect(screen.getByRole('button')).not.toHaveAttribute('aria-current'); + expect(container.firstChild).not.toHaveAttribute('aria-current'); }); - it('does not set aria-current when isActive is omitted', () => { - render(); + it('sets aria-current="true" on the baseline-text button when isActive is true', () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toHaveAttribute('aria-current', 'true'); + }); + + it('calls onSelect when clicked in baseline-text mode', async () => { + const handleSelect = jest.fn(); + render( + + + , + ); + + await userEvent.click(screen.getByTestId('segment-container')); - expect(screen.getByRole('button')).not.toHaveAttribute('aria-current'); + expect(handleSelect).toHaveBeenCalledTimes(1); + expect(handleSelect).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 1 }); }); - it('calls onClick when the button is clicked', async () => { - const handleClick = jest.fn(); - render(); + it('calls onSelect with the verse ref and token id when a word token is clicked', async () => { + const handleSelect = jest.fn(); + render( + + + , + ); - await userEvent.click(screen.getByRole('button')); + await userEvent.click(screen.getByRole('button', { name: 'In' })); - expect(handleClick).toHaveBeenCalledTimes(1); + expect(handleSelect).toHaveBeenCalledTimes(1); + expect(handleSelect).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 1 }, 'tok-0'); }); - it('does not throw when onClick is omitted and button is clicked', async () => { - render(); + it('renders word tokens as interactive buttons when onSelect is provided', () => { + render( + + + , + ); - await userEvent.click(screen.getByRole('button')); - // No assertion needed — test passes if no error is thrown + expect(screen.getByRole('button', { name: 'In' })).toBeInTheDocument(); }); }); diff --git a/src/__tests__/components/TokenChip.test.tsx b/src/__tests__/components/TokenChip.test.tsx index cf979ba5..ddf9a1a3 100644 --- a/src/__tests__/components/TokenChip.test.tsx +++ b/src/__tests__/components/TokenChip.test.tsx @@ -3,54 +3,216 @@ /// import { render, screen } from '@testing-library/react'; -import type { Token } from 'interlinearizer'; -import { TokenChip } from '../../components/TokenChip'; +import userEvent from '@testing-library/user-event'; +import type { AssignmentStatus, Token } from 'interlinearizer'; +import type { ReactNode } from 'react'; +import { AnalysisStoreProvider } from '../../components/AnalysisStore'; +import { InertTokenChip, TokenChip } from '../../components/TokenChip'; -const WORD_TOKEN: Token = { +// --------------------------------------------------------------------------- +// AnalysisStore mock — reactive useState-based stub so AnalysisStore.tsx stays out of scope +// --------------------------------------------------------------------------- + +jest.mock('../../components/AnalysisStore', () => { + const { createContext, useCallback, useContext, useMemo, useState } = + jest.requireActual('react'); + + type GlossMap = Record; + type MockCtxValue = { + glosses: GlossMap; + dispatch: (tokenRef: string, surfaceText: string, value: string) => void; + }; + const MockCtx = createContext({ glosses: {}, dispatch: () => {} }); + + return { + __esModule: true, + AnalysisStoreProvider({ + children, + initialAnalysis, + analysisLanguage, + onGlossChange, + }: Readonly<{ + children: ReactNode; + initialAnalysis?: { + tokenAnalyses: { id: string; gloss?: GlossMap }[]; + tokenAnalysisLinks: { + analysisId: string; + status: AssignmentStatus; + token: { tokenRef: string }; + }[]; + }; + analysisLanguage: string; + onGlossChange?: (tokenRef: string, value: string) => void; + }>) { + const byId = new Map((initialAnalysis?.tokenAnalyses ?? []).map((ta) => [ta.id, ta])); + const seed: GlossMap = (initialAnalysis?.tokenAnalysisLinks ?? []) + .filter((link) => link.status === 'approved') + .reduce((acc, link) => { + const gloss = byId.get(link.analysisId)?.gloss?.[analysisLanguage]; + return gloss === undefined ? acc : { ...acc, [link.token.tokenRef]: gloss }; + }, {}); + const [glosses, setGlosses] = useState(seed); + const dispatch = useCallback( + (tokenRef: string, _surfaceText: string, value: string) => { + setGlosses((prev) => ({ ...prev, [tokenRef]: value })); + onGlossChange?.(tokenRef, value); + }, + [onGlossChange], + ); + const ctx = useMemo(() => ({ glosses, dispatch }), [glosses, dispatch]); + return {children}; + }, + useGloss(tokenId: string) { + return useContext(MockCtx).glosses[tokenId] ?? ''; + }, + useGlossDispatch() { + return useContext(MockCtx).dispatch; + }, + }; +}); + +const WORD_TOKEN = { ref: 'GEN 1:1:0', surfaceText: 'hello', writingSystem: 'en', type: 'word', charStart: 0, charEnd: 5, -}; +} satisfies Token; -const PUNCT_TOKEN: Token = { - ref: 'GEN 1:1:5', +/** + * Minimal required props for {@link TokenChip}. Spread into render calls so tests only need to + * override what they actually care about. + * + * @returns An object with all required props set to no-op stubs. + */ +function requiredProps(): { token: Token & { type: 'word' }; onFocus: () => void } { + return { + token: WORD_TOKEN, + onFocus: jest.fn(), + }; +} + +const PUNCT_TOKEN = { + ref: 'GEN 1:1:p', surfaceText: '.', writingSystem: 'en', type: 'punctuation', charStart: 5, charEnd: 6, -}; +} satisfies Token; + +describe('InertTokenChip', () => { + it('renders the surface text', () => { + render(); + expect(screen.getByText('.')).toBeInTheDocument(); + }); + + it('renders as an inline span', () => { + render(); + expect(screen.getByText('.').tagName).toBe('SPAN'); + }); +}); describe('TokenChip', () => { - it('renders the surface text for a word token', () => { - render(); + it('renders the surface text', () => { + render( + + + , + ); expect(screen.getByText('hello')).toBeInTheDocument(); }); - it('renders the surface text for a punctuation token', () => { - render(); - expect(screen.getByText('.')).toBeInTheDocument(); + it('applies a border class to the outer container', () => { + render( + + + , + ); + const outer = screen.getByText('hello').closest('span')?.parentElement; + expect(outer?.className).toContain('tw:border'); + }); + + it('renders a gloss input', () => { + render( + + + , + ); + expect(screen.getByRole('textbox', { name: 'Gloss for hello' })).toBeInTheDocument(); + }); + + it('shows the current gloss value from the store', () => { + const initialAnalysis = { + tokenAnalyses: [{ id: 'ta-1', surfaceText: 'hello', gloss: { und: 'in' } }], + tokenAnalysisLinks: [ + { + analysisId: 'ta-1', + status: 'approved', + token: { tokenRef: 'GEN 1:1:0', surfaceText: 'hello' }, + } satisfies { + analysisId: string; + status: AssignmentStatus; + token: { tokenRef: string; surfaceText: string }; + }, + ], + segmentAnalyses: [], + segmentAnalysisLinks: [], + phraseAnalyses: [], + phraseAnalysisLinks: [], + }; + render( + + + , + ); + expect(screen.getByRole('textbox', { name: 'Gloss for hello' })).toHaveValue('in'); + }); + + it('shows an empty string in the input when no gloss has been set', () => { + render( + + + , + ); + expect(screen.getByRole('textbox', { name: 'Gloss for hello' })).toHaveValue(''); }); - it('applies a border class to word tokens', () => { - render(); - const span = screen.getByText('hello'); - expect(span.className).toContain('tw:border'); + it('calls the store onGlossChange spy once on blur with the final value', async () => { + const spy = jest.fn(); + render( + + + , + ); + await userEvent.type(screen.getByRole('textbox', { name: 'Gloss for hello' }), 'in'); + expect(spy).not.toHaveBeenCalled(); + await userEvent.tab(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('GEN 1:1:0', 'in'); }); - it('does not apply a border class to punctuation tokens', () => { - render(); - const span = screen.getByText('.'); - expect(span.className).not.toContain('tw:border'); + it('does not call the store onGlossChange spy when blurring without typing', async () => { + const spy = jest.fn(); + render( + + + , + ); + await userEvent.click(screen.getByRole('textbox', { name: 'Gloss for hello' })); + await userEvent.tab(); + expect(spy).not.toHaveBeenCalled(); }); - it('renders word and punctuation tokens as inline spans', () => { - const { container: wc } = render(); - const { container: pc } = render(); - expect(wc.querySelector('span')).toBeInTheDocument(); - expect(pc.querySelector('span')).toBeInTheDocument(); + it('calls onFocus when the input is focused', async () => { + const handleFocus = jest.fn(); + render( + + + , + ); + await userEvent.click(screen.getByRole('textbox', { name: 'Gloss for hello' })); + expect(handleFocus).toHaveBeenCalledTimes(1); }); }); diff --git a/src/__tests__/hooks/useInterlinearizerBookData.test.ts b/src/__tests__/hooks/useInterlinearizerBookData.test.ts index 1b8c9949..2b0b2c4f 100644 --- a/src/__tests__/hooks/useInterlinearizerBookData.test.ts +++ b/src/__tests__/hooks/useInterlinearizerBookData.test.ts @@ -111,7 +111,7 @@ describe('useInterlinearizerBookData', () => { setupDefaultProjectSettingMock(); }); - it('returns isLoading=true and no book or error while data is still loading', () => { + it('returns isLoading=true and no book when USJ data has not arrived', () => { jest.mocked(useProjectData).mockReturnValue({ BookUSJ: () => [undefined, jest.fn(), true] }); jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); @@ -266,7 +266,7 @@ describe('useInterlinearizerBookData', () => { ); }); - it('logs tokenization error with PlatformError writing system', () => { + it('logs tokenization error with the resolved writing system', () => { const platformError: PlatformError = { message: 'Setting unavailable', platformErrorVersion: 1, @@ -294,29 +294,4 @@ describe('useInterlinearizerBookData', () => { }, ); }); - - it('logs tokenization error with empty string writing system', () => { - jest.mocked(useProjectSetting).mockReturnValue(['', jest.fn(), jest.fn(), false]); - jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); - - const error = new Error('Tokenization failed'); - jest.mocked(tokenizeBook).mockImplementation(() => { - throw error; - }); - - renderHook(() => - useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), - ); - - expect(jest.mocked(logger.error)).toHaveBeenCalledWith( - 'Failed to parse/tokenize USJ book', - error, - { - book: 'GEN', - message: 'Tokenization failed', - projectId: 'test-project', - writingSystem: 'und', - }, - ); - }); }); diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts index bfabdb64..a39b4346 100644 --- a/src/__tests__/test-helpers.ts +++ b/src/__tests__/test-helpers.ts @@ -1,9 +1,11 @@ /** * @file Test helpers used to build type-safe mocks without type assertions. Provides a minimal - * ExecutionActivationContext that satisfies @papi/core types, and a `useWebViewState` hook stub - * for component tests. + * ExecutionActivationContext that satisfies @papi/core types, a `useWebViewState` hook stub for + * component tests, and shared Book fixtures. */ +import type { SerializedVerseRef } from '@sillsdev/scripture'; import type { ExecutionActivationContext } from '@papi/core'; +import type { Book } from 'interlinearizer'; import { UnsubscriberAsyncList } from 'platform-bible-utils'; /** Minimal execution token-shaped object for tests (structural match for ExecutionToken). */ @@ -56,6 +58,34 @@ export function makeWebViewState() { }; } +/** Genesis 1:1 serialized verse ref — shared across tests that need a default scroll position. */ +export const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + +/** Pre-built Book with one GEN 1:1 segment and a single word token. */ +export const GEN_1_1_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: 'In the beginning.', + tokens: [ + { + ref: 'GEN 1:1:0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + ], + }, + ], +}; + /** Minimal elevated privileges for tests (all properties optional per papi type). */ const mockElevatedPrivileges = { createProcess: undefined, diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx new file mode 100644 index 00000000..e4d98330 --- /dev/null +++ b/src/components/AnalysisStore.tsx @@ -0,0 +1,295 @@ +/** @file External analysis store with per-token subscriptions via `useSyncExternalStore`. */ +import type { TextAnalysis, TokenAnalysis, TokenAnalysisLink } from 'interlinearizer'; +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useSyncExternalStore, +} from 'react'; +import type { ReactNode } from 'react'; + +/** + * Shape of the React context value provided by {@link AnalysisStoreProvider}. Consumed by + * {@link useGloss}, {@link useAnalysis}, and {@link useGlossDispatch}. + */ +type AnalysisStoreContextValue = { + /** Registers a listener that fires whenever any analysis data changes. Returns an unsubscribe fn. */ + subscribe: (onStoreChange: () => void) => () => void; + /** Returns the current approved gloss string for `tokenRef` in the active language, or `''`. */ + getGloss: (tokenRef: string) => string; + /** + * Returns the entire current `TextAnalysis` snapshot. Same reference is returned until the next + * mutation so `useSyncExternalStore` detects changes via reference equality. + */ + getAnalysis: () => TextAnalysis; + /** + * Creates a new approved `TokenAnalysis` + `TokenAnalysisLink` for `tokenRef` with the given + * gloss, notifies subscribers, and calls the `onSave` callback with the updated analysis. + */ + onGlossChange: (tokenRef: string, surfaceText: string, value: string) => void; +}; + +const AnalysisStoreCtx = createContext(undefined); + +/** Props for {@link AnalysisStoreProvider}. */ +type AnalysisStoreProviderProps = Readonly<{ + children: ReactNode; + /** BCP 47 analysis-language tag used when reading and writing `TokenAnalysis.gloss` values. */ + analysisLanguage: string; + /** + * The initial `TextAnalysis` to seed the store. Not reactive after mount — the caller is + * responsible for unmounting and remounting when the active project changes. + */ + initialAnalysis?: TextAnalysis; + /** + * Called after every store mutation with the updated `TextAnalysis`. Use this to persist changes + * back to the active project's storage. + */ + onSave?: (analysis: TextAnalysis) => void; + /** + * Optional spy called after each gloss write. Intended for test observability only — has no + * effect on store behaviour. + */ + onGlossChange?: (tokenRef: string, value: string) => void; +}>; + +/** Empty `TextAnalysis` used as the default when no `initialAnalysis` is provided. */ +const EMPTY_ANALYSIS: TextAnalysis = { + segmentAnalyses: [], + segmentAnalysisLinks: [], + tokenAnalyses: [], + tokenAnalysisLinks: [], + phraseAnalyses: [], + phraseAnalysisLinks: [], +}; + +/** + * Builds a lookup from `tokenRef` to the approved `TokenAnalysis.id` for that token. Only the last + * approved link per token is indexed (the data model invariant says at most one should be approved; + * this is a graceful tie-break when that invariant is violated). + * + * @param analysis - The `TextAnalysis` to index. + * @param analysisById - Pre-built map of `TokenAnalysis.id` → `TokenAnalysis`. + * @returns A map from `tokenRef` → approved `TokenAnalysis.id`. + */ +function buildApprovedGlossIndex( + analysis: TextAnalysis, + analysisById: Map, +): Map { + return analysis.tokenAnalysisLinks.reduce((index, link) => { + if (link.status === 'approved' && analysisById.has(link.analysisId)) { + index.set(link.token.tokenRef, link.analysisId); + } + return index; + }, new Map()); +} + +/** + * Provides a `TextAnalysis`-backed store to the subtree. Components inside can read per-token + * approved gloss values via {@link useGloss} and write new approved analyses via + * {@link useGlossDispatch}. The full analysis snapshot is accessible via {@link useAnalysis}. + * + * @param props - Component props + * @param props.children - Subtree that should have access to the analysis store + * @param props.initialAnalysis - Seed `TextAnalysis`; not reactive after mount + * @param props.analysisLanguage - BCP 47 tag for reading/writing gloss values + * @param props.onSave - Callback receiving the updated `TextAnalysis` after each mutation + * @param props.onGlossChange - Spy called after each gloss write; for test observability only + * @returns A context provider wrapping the subtree + */ +export function AnalysisStoreProvider({ + children, + initialAnalysis, + analysisLanguage, + onSave, + onGlossChange: spy, +}: AnalysisStoreProviderProps) { + const analysisRef = useRef(initialAnalysis ?? EMPTY_ANALYSIS); + const listenersRef = useRef(new Set<() => void>()); + + // These two indexes are built lazily via ??= so that passing an initializer expression to useRef + // (which evaluates on every render but is only used on the first mount) doesn't rebuild large Maps + // across a full-Bible analysis on every re-render. + + /** Pre-built map of `TokenAnalysis.id` → `TokenAnalysis` for O(1) lookup by id. */ + const analysisByIdRef = useRef | undefined>(undefined); + analysisByIdRef.current ??= new Map(analysisRef.current.tokenAnalyses.map((ta) => [ta.id, ta])); + + /** + * Pre-built map of `tokenRef` → approved `TokenAnalysis.id` for the active language. Reset on + * every mutation that changes the analysis. + */ + const approvedAnalysisIdByTokenRef = useRef | undefined>(undefined); + approvedAnalysisIdByTokenRef.current ??= buildApprovedGlossIndex( + analysisRef.current, + analysisByIdRef.current, + ); + + /** + * Registers `listener` to be called whenever any analysis data changes. Returns an unsubscribe + * function that removes the listener from the set. + * + * @param listener - Zero-argument callback invoked after every store mutation. + * @returns An unsubscribe function that, when called, removes the listener. + */ + const subscribe = useCallback((listener: () => void) => { + listenersRef.current.add(listener); + return () => { + listenersRef.current.delete(listener); + }; + }, []); + + /** + * Returns the approved gloss string for `tokenRef` in the active `analysisLanguage`, or `''` when + * no approved analysis exists for the token. + * + * @param tokenRef - The token reference to look up. + * @returns The approved gloss string, or `''` when absent. + */ + const getGloss = useCallback( + (tokenRef: string) => { + // eslint-disable-next-line no-type-assertion/no-type-assertion -- ??= above guarantees non-null; TS can't see through the closure boundary + const analysisId = approvedAnalysisIdByTokenRef.current!.get(tokenRef); + if (!analysisId) return ''; + // eslint-disable-next-line no-type-assertion/no-type-assertion -- same: ??= guarantees non-null + const ta = analysisByIdRef.current!.get(analysisId); + /* v8 ignore next -- optional chaining on ta?.gloss produces a branch V8 cannot reach through the mock */ + return ta?.gloss?.[analysisLanguage] ?? ''; + }, + [analysisLanguage], + ); + + /** + * Returns the current `TextAnalysis` snapshot. The same reference is returned on every call until + * the next mutation so `useSyncExternalStore` can detect changes via reference equality. + * + * @returns The current `TextAnalysis`; a new object reference is produced on each mutation. + */ + const getAnalysis = useCallback(() => analysisRef.current, []); + + /** + * Writes an approved gloss for `tokenRef`. If an approved `TokenAnalysis` already exists for the + * token it is updated in-place; otherwise a new `TokenAnalysis` + `TokenAnalysisLink` are + * appended. Non-approved analyses for the token are left untouched. Replaces the analysis + * snapshot, notifies subscribers, calls `onSave`, and calls the optional `spy` prop for test + * observability. + * + * @param tokenRef - The `Token.ref` of the token being glossed. + * @param surfaceText - The surface text of the token (stored as `Analysis.surfaceText`). + * @param value - The new gloss string. + */ + const onGlossChange = useCallback( + (tokenRef: string, surfaceText: string, value: string) => { + // eslint-disable-next-line no-type-assertion/no-type-assertion -- ??= above guarantees non-null; TS can't see through the closure boundary + const existingApprovedId = approvedAnalysisIdByTokenRef.current!.get(tokenRef); + + let nextAnalyses: TokenAnalysis[]; + let nextLinks: TokenAnalysisLink[]; + let nextById: Map; + + if (existingApprovedId === undefined) { + const id = crypto.randomUUID(); + const newAnalysis: TokenAnalysis = { + id, + surfaceText, + gloss: { [analysisLanguage]: value }, + }; + const newLink: TokenAnalysisLink = { + analysisId: id, + status: 'approved', + token: { tokenRef, surfaceText }, + }; + nextAnalyses = [...analysisRef.current.tokenAnalyses, newAnalysis]; + nextLinks = [...analysisRef.current.tokenAnalysisLinks, newLink]; + // eslint-disable-next-line no-type-assertion/no-type-assertion -- ??= above guarantees non-null + nextById = new Map([...analysisByIdRef.current!, [id, newAnalysis]]); + } else { + // Update the gloss on the existing approved analysis; preserve all other fields. + // eslint-disable-next-line no-type-assertion/no-type-assertion -- ??= above guarantees non-null; index only stores ids present in analysisByIdRef + const existing = analysisByIdRef.current!.get(existingApprovedId)!; + const updated: TokenAnalysis = { + ...existing, + surfaceText, + gloss: { ...existing.gloss, [analysisLanguage]: value }, + }; + nextAnalyses = analysisRef.current.tokenAnalyses.map( + /* v8 ignore next -- passthrough branch for non-matching tokens is structurally unreachable in tests */ + (ta) => (ta.id === existingApprovedId ? updated : ta), + ); + // Links are unchanged — the same link already points to existingApprovedId. + nextLinks = analysisRef.current.tokenAnalysisLinks; + // eslint-disable-next-line no-type-assertion/no-type-assertion -- ??= above guarantees non-null + nextById = new Map([...analysisByIdRef.current!, [existingApprovedId, updated]]); + } + + const next: TextAnalysis = { + ...analysisRef.current, + tokenAnalyses: nextAnalyses, + tokenAnalysisLinks: nextLinks, + }; + + analysisRef.current = next; + analysisByIdRef.current = nextById; + approvedAnalysisIdByTokenRef.current = buildApprovedGlossIndex(next, nextById); + + listenersRef.current.forEach((l) => l()); + onSave?.(next); + spy?.(tokenRef, value); + }, + [analysisLanguage, onSave, spy], + ); + + const ctx = useMemo( + () => ({ subscribe, getGloss, getAnalysis, onGlossChange }), + [subscribe, getGloss, getAnalysis, onGlossChange], + ); + + return {children}; +} + +/** + * Returns the approved gloss string for the given token in the store's active analysis language, + * re-rendering only when that token's approved analysis changes. + * + * @param tokenRef - The token whose gloss to read. + * @returns The current approved gloss string, or `''` when no approved analysis exists. + * @throws When called outside an {@link AnalysisStoreProvider}. + */ +export function useGloss(tokenRef: string): string { + const ctx = useContext(AnalysisStoreCtx); + if (!ctx) throw new Error('useGloss must be used inside an AnalysisStoreProvider'); + + const getSnapshot = useMemo(() => () => ctx.getGloss(tokenRef), [ctx, tokenRef]); + + return useSyncExternalStore(ctx.subscribe, getSnapshot); +} + +/** + * Returns the current `TextAnalysis` snapshot, re-rendering on every analysis change. Intended for + * components that need the full analysis (e.g. an analysis-selection popup). + * + * @returns The current `TextAnalysis` from the nearest {@link AnalysisStoreProvider}. + * @throws When called outside an {@link AnalysisStoreProvider}. + */ +export function useAnalysis(): TextAnalysis { + const ctx = useContext(AnalysisStoreCtx); + if (!ctx) throw new Error('useAnalysis must be used inside an AnalysisStoreProvider'); + + return useSyncExternalStore(ctx.subscribe, ctx.getAnalysis); +} + +/** + * Returns the stable `onGlossChange` callback from the nearest {@link AnalysisStoreProvider}. The + * callback creates a new approved `TokenAnalysis` for the token on each call. + * + * @returns A function `(tokenRef, surfaceText, value) => void`. + * @throws When called outside an {@link AnalysisStoreProvider}. + */ +export function useGlossDispatch(): (tokenRef: string, surfaceText: string, value: string) => void { + const ctx = useContext(AnalysisStoreCtx); + if (!ctx) throw new Error('useGlossDispatch must be used inside an AnalysisStoreProvider'); + + return ctx.onGlossChange; +} diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index 2b7a2091..279c9b2a 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -1,16 +1,56 @@ import type { Book, ScriptureRef, Token } from 'interlinearizer'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { isWordToken } from './component-types'; import MemoizedPhraseBox from './PhraseBox'; -import MemoizedTokenChip from './TokenChip'; +import { MemoizedInertTokenChip } from './TokenChip'; -/** CSS easing for the strip opacity fade-in/out animation. */ +/** + * Clamps `index` to `[0, len - 1]`, returning `0` when `len` is zero. + * + * @param index - The raw index to clamp. + * @param len - Length of the target array. + * @returns A safe index guaranteed to be within bounds. + */ +function clampIndex(index: number, len: number): number { + if (len === 0) return 0; + return Math.max(0, Math.min(index, len - 1)); +} + +/** + * CSS easing for the strip opacity fade-in/out animation. Uses a sine-like curve for a natural feel + * at both ends of the transition. + */ const STRIP_FADE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)'; + /** * Duration of the strip fade animation in milliseconds. Must match the `setTimeout` in the * pending-jump effect. */ const STRIP_FADE_MS = 500; +/** + * Number of phrase slots rendered on each side of the focused phrase. Chosen large enough that no + * realistic viewport can ever render all tokens simultaneously. + */ +const PHRASE_WINDOW_HALF = 100; + +/** Props for {@link ContinuousView}. */ +type ContinuousViewProps = Readonly<{ + /** + * When set, the strip jumps to this phrase index. Used to carry over a focused token when + * switching from segment view. + */ + activePhraseIndex: number | undefined; + /** Verse coordinate; when it changes the strip scrolls to the first token of that segment. */ + activeVerse: ScriptureRef; + /** The full tokenized book whose tokens are streamed into the strip. */ + book: Book; + /** Called whenever the focused phrase index changes so the parent can mirror the strip position. */ + onFocusPhraseIndexChange: (index: number) => void; + /** Called when arrow navigation moves the focus into a new verse. */ + onVerseChange: (verse: ScriptureRef) => void; +}>; + /** * Renders all tokens from every segment in the given book as a single flat, horizontally scrollable * strip. Arrow buttons advance or retreat the view by one token at a time with smooth scrolling @@ -26,21 +66,23 @@ const STRIP_FADE_MS = 500; * navigation crosses a verse boundary `onVerseChange` is called with the new verse coordinate. * * @param props - Component props - * @param props.activeVerse - Optional verse coordinate; when it changes the strip scrolls to the - * first token of the matching segment + * @param props.activePhraseIndex - When set, the strip jumps to this phrase index; used to carry + * over a focused token when switching from segment view + * @param props.activeVerse - Verse coordinate; when it changes the strip scrolls to the first token + * of the matching segment * @param props.book - The full tokenized book whose tokens should be streamed + * @param props.onFocusPhraseIndexChange - Called whenever the focused phrase index changes so the + * parent can mirror the strip position * @param props.onVerseChange - Called when arrow navigation moves the focus into a new verse * @returns A horizontal token strip with previous/next navigation arrows and edge-fade overlays */ export default function ContinuousView({ + activePhraseIndex, activeVerse, book, + onFocusPhraseIndexChange, onVerseChange, -}: Readonly<{ - activeVerse?: ScriptureRef; - book: Book; - onVerseChange?: (verse: ScriptureRef) => void; -}>) { +}: ContinuousViewProps) { const isRtl = document.documentElement.dir === 'rtl'; const allTokens: Token[] = useMemo( @@ -69,6 +111,20 @@ export default function ContinuousView({ .filter((entry) => entry.token.type === 'word'), [allTokens], ); + + /** + * Stable single-token arrays indexed by position in `allTokens`, so `MemoizedPhraseBox` receives + * the same array reference across renders and shallow memo comparison holds. + */ + const tokenArrays = useMemo( + () => allTokens.map((token) => (isWordToken(token) ? [token] : [])), + [allTokens], + ); + + /** + * Ref mirror of `phraseEntries`. Read inside effects and callbacks that need the latest list + * without declaring it as a dependency (which would cause spurious re-runs). + */ const phraseEntriesRef = useRef(phraseEntries); phraseEntriesRef.current = phraseEntries; @@ -125,9 +181,10 @@ export default function ContinuousView({ ); // Lazy-initialize to the target verse so on first render the strip is already positioned - // correctly before the initial-load fade-in fires. + // correctly before the initial-load fade-in fires. Prefer activePhraseIndex (e.g. a focused token + // carried over from segment view) so there is no flash to the verse-start position on mount. const [focusPhraseIndex, setFocusPhraseIndex] = useState(() => { - if (!activeVerse) return 0; + if (activePhraseIndex !== undefined) return clampIndex(activePhraseIndex, phraseEntries.length); const seg = book.segments.find( (s) => @@ -146,15 +203,29 @@ export default function ContinuousView({ return phraseIndexByTokenIndex.get(tokenIdx) ?? 0; }); + /** + * The phrase index of the most recent external jump (prop-driven). Read inside the + * `focusPhraseIndex` effect to suppress the echo-back verse-change notification that would + * otherwise fire when the strip repositions itself in response to an incoming prop. + */ const jumpTargetRef = useRef(undefined); const [pendingExternalJumpPhraseIndex, setPendingExternalJumpPhraseIndex] = useState< number | undefined >(); const [isVisible, setIsVisible] = useState(false); + /** True while an externally triggered jump (prop change) is in progress; suppresses smooth scroll. */ const isExternalJumpInProgressRef = useRef(false); + /** True until the first scroll-into-view completes; suppresses smooth scroll on initial mount. */ const isInitialLoadInProgressRef = useRef(true); + /** + * True when the lazy `useState` initializer already positioned the strip at `activePhraseIndex`, + * so the first run of the `activePhraseIndex` effect should be skipped to avoid a redundant + * jump. + */ + const activePhraseIndexAppliedRef = useRef(activePhraseIndex !== undefined); + /** * Records the verse most recently reported via `onVerseChange`. When the parent echoes that verse * back as `activeVerse` we skip the jump — the change originated here, not externally. @@ -163,10 +234,33 @@ export default function ContinuousView({ */ const lastInternalVerseRef = useRef(activeVerse); - // Jump to the first token of the matching segment when the active verse changes. + // These two effects (activePhraseIndex and activeVerse) could theoretically race: if both props + // changed in one render, the activeVerse effect would overwrite the activePhraseIndex jump, + // scrolling to verse-start rather than the exact token. This is safe because Interlinearizer + // only passes activePhraseIndex when continuousScroll is false (segment mode), where ContinuousView + // is unmounted. When continuousScroll is true, SegmentView renders in baseline-text mode and + // onSelect is called without a tokenId, so activePhraseIndex is never set from within continuous + // mode. Any future change that adds token-level clicks in continuous mode must revisit this. + + // Jump to a specific phrase index when activePhraseIndex changes. useEffect(() => { - if (!activeVerse) return; + if (activePhraseIndex === undefined) return; + + // Skip the first run when the lazy initializer already positioned the strip here. + if (activePhraseIndexAppliedRef.current) { + activePhraseIndexAppliedRef.current = false; + return; + } + const clamped = clampIndex(activePhraseIndex, phraseEntriesRef.current.length); + jumpTargetRef.current = clamped; + isExternalJumpInProgressRef.current = true; + setIsVisible(false); + setPendingExternalJumpPhraseIndex(clamped); + }, [activePhraseIndex]); + + // Jump to the first token of the matching segment when the active verse changes. + useEffect(() => { // Skip if this activeVerse update is an echo-back of a verse change we reported ourselves. const lastInternal = lastInternalVerseRef.current; if ( @@ -202,13 +296,17 @@ export default function ContinuousView({ }, [pendingExternalJumpPhraseIndex]); // Fire onVerseChange when arrow navigation crosses into a new verse. - // Initialise to the segment that owns the initial focusPhraseIndex so the initial render does not trigger the callback. + // Initialize to the segment that owns the initial focusPhraseIndex so the initial render does not trigger the callback. const firstVisibleSegId = phraseEntries.length > 0 ? tokenSegment[phraseEntries[0].tokenIndex]?.id : undefined; const initialFocusedPhrase = phraseEntries[focusPhraseIndex]; const initialSegId = initialFocusedPhrase ? tokenSegment[initialFocusedPhrase.tokenIndex]?.id : firstVisibleSegId; + /** + * Segment id of the last verse reported via `onVerseChange`. Compared against the current focused + * segment to avoid firing the callback redundantly when focus stays within the same verse. + */ const lastReportedSegIdRef = useRef(initialSegId); // Keep the reported-segment baseline in sync when switching to a different book. @@ -238,19 +336,47 @@ export default function ContinuousView({ verse: seg.startRef.verse, }; lastInternalVerseRef.current = verse; - onVerseChange?.(verse); + onVerseChange(verse); // onVerseChange and tokenSegmentRef are intentionally excluded — callers must stabilize the // reference (useCallback) and tokenSegmentRef is a ref so changes are always current. // eslint-disable-next-line react-hooks/exhaustive-deps }, [focusPhraseIndex]); - // One ref slot per phrase so we can call scrollIntoView on the focused one. + /** Ref mirror of `onFocusPhraseIndexChange` so the notification effect never needs it as a dep. */ + const onFocusPhraseIndexChangeRef = useRef(onFocusPhraseIndexChange); + onFocusPhraseIndexChangeRef.current = onFocusPhraseIndexChange; + // Intentionally fires on mount with the lazy-initialized focusPhraseIndex. This notifies the + // parent of the initial strip position so the segment list scrolls the active verse into view + // on first render. The coupling is load-bearing — do not add an early-return guard here. + useEffect(() => { + onFocusPhraseIndexChangeRef.current(focusPhraseIndex); + }, [focusPhraseIndex]); + + /** DOM ref array indexed by phrase index; used to scroll the focused chip into view. */ const phraseRefs = useRef<(HTMLSpanElement | null)[]>([]); const atStart = phraseEntries.length === 0 || focusPhraseIndex === 0; const atEnd = phraseEntries.length === 0 || focusPhraseIndex >= phraseEntries.length - 1; const stripOpacityClass = isVisible ? 'tw:opacity-100' : 'tw:opacity-0'; + /** The inclusive phrase-index bounds of the rendered window. */ + const windowStart = Math.max(0, focusPhraseIndex - PHRASE_WINDOW_HALF); + const windowEnd = Math.min(phraseEntries.length - 1, focusPhraseIndex + PHRASE_WINDOW_HALF); + + /** Token index of the first token in the rendered window. */ + const windowStartTokenIndex = + phraseEntries.length > 0 && windowStart > 0 ? phraseEntries[windowStart].tokenIndex : 0; + + // windowEndTokenIndex stops at the last word token in the window, so punctuation tokens that + // trail it (before the next word) are excluded from the rendered slice. Punctuation before the + // window's first word IS included (windowStartTokenIndex points at the word itself). This + // asymmetry is invisible with PHRASE_WINDOW_HALF=100 but would matter if the window shrinks. + /** Token index one past the last token in the rendered window. */ + const windowEndTokenIndex = + phraseEntries.length > 0 && windowEnd < phraseEntries.length - 1 + ? phraseEntries[windowEnd].tokenIndex + 1 + : allTokens.length; + /** * Advances the focused phrase by `delta` positions, clamping to valid bounds. * @@ -307,9 +433,10 @@ export default function ContinuousView({ return () => { cancelAnimationFrame(rafId); - // If the RAF was cancelled (another focus change fired before the first frame), - // still reveal the strip so it is not left invisible. - setIsVisible(true); + // Only reveal the strip on cleanup if no new external jump is about to take over. + // When a second click arrives before this RAF fires, isExternalJumpInProgressRef is already + // true for the new jump — revealing here would make the strip visible before it has scrolled. + if (!isExternalJumpInProgressRef.current) setIsVisible(true); }; }, [focusPhraseIndex]); @@ -346,14 +473,17 @@ export default function ContinuousView({ {/* Inner flex row */}
- {allTokens.map((token, tokenIndex) => { - if (token.type !== 'word') return ; + {allTokens.slice(windowStartTokenIndex, windowEndTokenIndex).map((token, i) => { + const tokenIndex = windowStartTokenIndex + i; + if (!isWordToken(token)) + return ; const phraseIndex = phraseIndexByTokenIndex.get(tokenIndex); const isFocusedPhrase = phraseIndex !== undefined && phraseIndex === focusPhraseIndex; @@ -367,8 +497,8 @@ export default function ContinuousView({ ); diff --git a/src/components/Interlinearizer.tsx b/src/components/Interlinearizer.tsx index 91175cc4..ae676dd6 100644 --- a/src/components/Interlinearizer.tsx +++ b/src/components/Interlinearizer.tsx @@ -1,85 +1,275 @@ import type { SerializedVerseRef } from '@sillsdev/scripture'; -import type { Book, ScriptureRef, Segment } from 'interlinearizer'; -import { useCallback } from 'react'; +import type { Book, ScriptureRef, Segment, TextAnalysis } from 'interlinearizer'; +import { LocateFixed } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AnalysisStoreProvider } from './AnalysisStore'; import ContinuousView from './ContinuousView'; import MemoizedSegmentView from './SegmentView'; +/** Props for {@link Interlinearizer}. */ +type InterlinearizerProps = Readonly<{ + /** Tokenized book whose segments are rendered. */ + book: Book; + /** Segments belonging to the current chapter, filtered by the caller. */ + chapterSegments: Segment[]; + /** When true, the horizontal token strip is shown above the segment list. */ + continuousScroll: boolean; + /** Current scripture reference used to highlight the active verse. */ + scrRef: SerializedVerseRef; + /** Called when the user navigates to a different verse. */ + setScrRef: (newScrRef: SerializedVerseRef) => void; + /** + * BCP 47 tag for reading and writing gloss values. Defaults to `analysisLanguages[0]` of the + * active project (supplied by the caller). + */ + analysisLanguage: string; + /** Initial analysis data seeded into the store; not reactive after mount. */ + initialAnalysis?: TextAnalysis; + /** Called after each gloss write with the updated `TextAnalysis` so the caller can persist it. */ + onSaveAnalysis?: (analysis: TextAnalysis) => void; +}>; + /** - * Main content area for the Interlinearizer. Renders an optional {@link ContinuousView} strip at the - * top followed by a scrollable list of {@link MemoizedSegmentView}s for the current chapter. + * Inner component that renders the segment list and continuous view. Separated from + * {@link Interlinearizer} so it can consume the `AnalysisStoreProvider` context that wraps it. * * @param props - Component props - * @param props.book - Book data used by the continuous view - * @param props.bookSegments - Segments to render as individual verse views - * @param props.continuousScroll - Whether the continuous scroll view is shown - * @param props.scrRef - Current scripture reference - * @param props.setScrRef - Callback to update the scripture reference - * @returns The continuous-view strip (when enabled) above the scrollable segment list for the - * active chapter. + * @param props.book - Tokenized book whose segments are rendered. + * @param props.chapterSegments - Segments belonging to the current chapter, filtered by the caller. + * @param props.continuousScroll - When true, the horizontal token strip is shown above the segment + * list. + * @param props.scrRef - Current scripture reference used to highlight the active verse. + * @param props.setScrRef - Called when the user navigates to a different verse. + * @returns The interlinearizer layout without the provider wrapper. */ -export default function Interlinearizer({ +function InterlinearizerInner({ book, - bookSegments, + chapterSegments, continuousScroll, scrRef, setScrRef, -}: Readonly<{ - book: Book; - bookSegments: Segment[]; - continuousScroll: boolean; - scrRef: SerializedVerseRef; - setScrRef: (newScrRef: SerializedVerseRef) => void; -}>) { +}: Omit) { + const [focusedTokenRef, setFocusedTokenRef] = useState(undefined); + + /** All word tokens in book order — index into this array is the phrase index. */ + const wordTokens = useMemo( + () => book.segments.flatMap((seg) => seg.tokens).filter((token) => token.type === 'word'), + [book.segments], + ); + + /** Maps each word token id to its phrase index across the full book. */ + const phraseIndexByTokenRef = useMemo( + () => + wordTokens.reduce((map, token, idx) => { + map.set(token.ref, idx); + return map; + }, new Map()), + [wordTokens], + ); + + // activePhraseIndex is intentionally not updated by ContinuousView arrow navigation — only token + // clicks via handleSegmentSelect change focusedTokenRef. Arrow navigation updates + // continuousViewPhraseIndex instead (via handleFocusPhraseIndexChange), and the mode-switch + // effect reads continuousViewPhraseIndexRef to recover the strip position when returning to + // segment view. The stale activePhraseIndex during arrow navigation is safe because ContinuousView + // manages its own focusPhraseIndex state and the unchanged prop doesn't re-trigger its effect. + const activePhraseIndex = + focusedTokenRef === undefined ? undefined : phraseIndexByTokenRef.get(focusedTokenRef); + /** - * Converts a `ScriptureRef` from `ContinuousView` into a `SerializedVerseRef` and forwards it to - * `setScrRef`. + * Tracks the continuous strip's current phrase index as reported by ContinuousView. Read inside + * the mode-switch effect to recover the strip position when returning to segment view. + */ + const continuousViewPhraseIndexRef = useRef(undefined); + + /** + * Keeps `continuousViewPhraseIndexRef` in sync with the strip's current position. * - * @param v - The verse coordinate reported by the continuous view. + * @param index - The phrase index now focused inside `ContinuousView`. */ - const handleVerseChange = useCallback( - (v: ScriptureRef) => { - setScrRef({ book: v.book, chapterNum: v.chapter, verseNum: v.verse }); + const handleFocusPhraseIndexChange = useCallback((index: number) => { + continuousViewPhraseIndexRef.current = index; + }, []); + + /** + * Previous value of `continuousScroll`; lets the mode-switch effect detect the transition + * direction. + */ + const prevContinuousScrollRef = useRef(continuousScroll); + + const scrollContainerRef = useRef(undefined); + + /** + * Ref callback that stores the scroll container element so imperative scroll calls can target it. + * + * @param el - The mounted div, or `null` on unmount. + */ + const setScrollContainer = useCallback((el: HTMLDivElement | null) => { + scrollContainerRef.current = el ?? undefined; + }, []); + + /** + * Scrolls the element marked `aria-current="true"` inside the scroll container into view at the + * top of the list. + */ + const snapToActive = useCallback(() => { + const container = scrollContainerRef.current; + const active = container?.querySelector('[aria-current="true"]'); + /* v8 ignore next -- active is always found when a verse is rendered; guard for empty lists */ + active?.scrollIntoView({ behavior: 'auto', block: 'start' }); + }, []); + + // When switching from continuous to segment view, carry over the focused phrase from the strip. + // Only acts on the continuous→segment transition; leaving segment view does not overwrite focus. + useEffect(() => { + const wasOn = prevContinuousScrollRef.current; + prevContinuousScrollRef.current = continuousScroll; + + if (!wasOn || continuousScroll) return; + + // Transitioning continuous → segment: prefer the strip's last known position. + const idx = continuousViewPhraseIndexRef.current; + const token = idx === undefined ? undefined : wordTokens[idx]; + if (token) { + setFocusedTokenRef(token.ref); + return; + } + + // Fallback: only move to first word of the active segment if nothing is focused yet. + setFocusedTokenRef((current) => { + if (current !== undefined) return current; + const activeSeg = chapterSegments.find((seg) => seg.startRef.verse === scrRef.verseNum); + return activeSeg?.tokens.find((t) => t.type === 'word')?.ref ?? current; + }); + // Only re-run when the mode switches; refs and stable arrays don't need to be deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [continuousScroll]); + + // Snap the segment list to the active verse when switching modes. + useEffect(() => { + snapToActive(); + // snapToActive is stable (useCallback with no changing deps), so this only re-runs on mode switch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [continuousScroll]); + + /** + * Updates the active scripture reference and, when a specific token was clicked, focuses that + * token. + * + * @param ref - The verse coordinate that was selected. + * @param tokenRef - The token that was clicked; omitted when the whole segment was selected. + */ + const handleSegmentSelect = useCallback( + (ref: ScriptureRef, tokenRef?: string) => { + setScrRef({ book: ref.book, chapterNum: ref.chapter, verseNum: ref.verse }); + if (tokenRef) setFocusedTokenRef(tokenRef); }, [setScrRef], ); + /** + * Updates the active scripture reference when ContinuousView reports a verse change via arrow + * navigation. A separate wrapper from `handleSegmentSelect` because verse changes from the strip + * never carry a token id. + * + * @param ref - The new verse coordinate reported by the strip. + */ + const handleVerseChange = useCallback( + (ref: ScriptureRef) => { + handleSegmentSelect(ref); + }, + [handleSegmentSelect], + ); + return (
{continuousScroll && (
)} -
- {bookSegments.length === 0 && ( +
+ {chapterSegments.length === 0 && (

No verse data for {scrRef.book} {scrRef.chapterNum}.

)} - {bookSegments.length > 0 && ( -
- {bookSegments.map((seg) => ( - - ))} -
+ {chapterSegments.length > 0 && ( + <> +
+ +
+ +
+ {chapterSegments.map((seg) => ( + + ))} +
+ )}
); } + +/** + * Main component for the Interlinearizer. Renders a sticky toolbar and continuous view at the top, + * followed by segmented views. Wraps the layout in an {@link AnalysisStoreProvider} so all + * descendant components can read and write analysis data without prop drilling. + * + * @param props - Component props + * @param props.book - Book data used by the continuous view + * @param props.chapterSegments - Segments to render as individual verse views + * @param props.continuousScroll - Whether the continuous scroll view is shown + * @param props.scrRef - Current scripture reference + * @param props.setScrRef - Callback to update the scripture reference + * @param props.initialAnalysis - Seed analysis data for the store; not reactive after mount + * @param props.analysisLanguage - BCP 47 tag for gloss read/write + * @param props.onSaveAnalysis - Called after each gloss write with the updated `TextAnalysis` + * @returns The full interlinearizer layout with optional continuous strip and segment list + */ +export default function Interlinearizer({ + initialAnalysis, + analysisLanguage, + onSaveAnalysis, + ...innerProps +}: InterlinearizerProps) { + return ( + + + + ); +} diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index 55368cdc..a5220ff1 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -13,6 +13,7 @@ import Interlinearizer from './Interlinearizer'; import ProjectModals, { type ModalState } from './ProjectModals'; import ScriptureNavControls from './ScriptureNavControls'; +/** Localized string keys used by {@link InterlinearizerLoader}. */ const STRING_KEYS: `%${string}%`[] = ['%interlinearizer_continuousScrollToggle%']; /** @@ -24,10 +25,10 @@ const STRING_KEYS: `%${string}%`[] = ['%interlinearizer_continuousScrollToggle%' * @param props.projectId - PAPI project ID passed from the host * @param props.useWebViewScrollGroupScrRef - Hook that exposes the shared scroll-group scripture * reference and its setter - * @param props.useWebViewState - Hook for reading and writing values persisted in the WebView's - * saved state (survives tab restores) - * @returns The interlinearizer layout: tab toolbar, loading/error states or main view, and any - * currently open project modal. + * @param props.useWebViewState - Hook for reading and writing typed WebView-scoped state persisted + * by the PAPI host + * @returns The toolbar and either an error/loading state or the fully rendered + * {@link Interlinearizer} */ export default function InterlinearizerLoader({ projectId, @@ -41,6 +42,11 @@ export default function InterlinearizerLoader({ const [scrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef(); const [interfaceMode] = useSetting('platform.interfaceMode', 'simple'); + const [interfaceLanguages] = useSetting('platform.interfaceLanguage', ['und']); + /* v8 ignore next 3 -- useSetting never returns PlatformError for this key in practice */ + const analysisLanguage = isPlatformError(interfaceLanguages) + ? 'und' + : interfaceLanguages[0] || 'und'; const { isLoading: isSettingLoading, @@ -182,10 +188,11 @@ export default function InterlinearizerLoader({ ) : ( )} diff --git a/src/components/PhraseBox.tsx b/src/components/PhraseBox.tsx index e0dd946c..101683f3 100644 --- a/src/components/PhraseBox.tsx +++ b/src/components/PhraseBox.tsx @@ -1,65 +1,53 @@ /** @file Shared phrase-box wrapper used around word tokens. */ import type { Token } from 'interlinearizer'; -import { memo } from 'react'; +import { memo, useCallback } from 'react'; import MemoizedTokenChip from './TokenChip'; +/** Props for {@link PhraseBox}. */ +type PhraseBoxProps = Readonly<{ + /** Index passed back to `onFocusPhrase` to identify which phrase gained focus. */ + index: number | undefined; + /** Whether this phrase is the current navigation focus. */ + isFocused: boolean; + /** Called with `index` when any child gloss input receives focus. */ + onFocusPhrase: (index?: number) => void; + /** Word tokens belonging to this phrase; must all have `type: 'word'`. */ + tokens: (Token & { type: 'word' })[]; +}>; + /** * Wraps one or more tokens in a phrase-level visual container. * * @param props - Component props - * @param props.index - Index passed back to `onClick` to identify which phrase was clicked + * @param props.index - Index passed back to `onFocusPhrase` to identify which phrase was focused * @param props.isFocused - Whether this phrase is the current navigation focus - * @param props.onClick - Called with `index` when the phrase is clicked; omit to render a - * non-interactive span + * @param props.onFocusPhrase - Called with `index` when any child gloss input receives focus * @param props.tokens - Tokens belonging to this phrase * @returns A bordered inline container */ -export function PhraseBox({ - index, - isFocused = false, - onClick, - tokens, -}: Readonly<{ - index?: number; - isFocused?: boolean; - onClick?: (index?: number) => void; - tokens: Token[]; -}>) { +export function PhraseBox({ index, isFocused = false, onFocusPhrase, tokens }: PhraseBoxProps) { const baseClass = isFocused - ? 'tw:inline-flex tw:items-center tw:rounded tw:border-2 tw:border-border tw:bg-muted/30 tw:px-1 tw:py-0.5' + ? 'tw:inline-flex tw:items-center tw:rounded tw:border-2 tw:border-white tw:bg-muted/30 tw:px-1 tw:py-0.5' : 'tw:inline-flex tw:items-center tw:rounded tw:border tw:border-border/40 tw:bg-muted/20 tw:px-1 tw:py-0.5'; - const innerContent = ( - - {tokens.map((token) => ( - - ))} - - ); - if (onClick) { - return ( - - ); - } + /** Notifies the parent when a child gloss input receives focus. */ + const handleFocus = useCallback(() => onFocusPhrase(index), [onFocusPhrase, index]); return ( - - {innerContent} - + + {tokens.map((token) => ( + + ))} + + ); } +/** Memoized version of {@link PhraseBox}; use in render-stable phrase lists. */ const MemoizedPhraseBox = memo(PhraseBox); export default MemoizedPhraseBox; diff --git a/src/components/ProjectModals.tsx b/src/components/ProjectModals.tsx index 93666b24..931db1c8 100644 --- a/src/components/ProjectModals.tsx +++ b/src/components/ProjectModals.tsx @@ -5,7 +5,7 @@ import { CreateProjectModal } from './CreateProjectModal'; import { ProjectMetadataModal } from './ProjectMetadataModal'; import { SelectInterlinearProjectModal } from './SelectInterlinearProjectModal'; -/** Which modal is currently visible. Only one can be open at a time. */ +/** Which project-related modal is currently open; `'none'` means no modal is visible. */ export type ModalState = 'none' | 'select' | 'create' | 'metadata'; /** diff --git a/src/components/SegmentView.tsx b/src/components/SegmentView.tsx index be7d1d78..153a4d90 100644 --- a/src/components/SegmentView.tsx +++ b/src/components/SegmentView.tsx @@ -1,7 +1,8 @@ import type { ScriptureRef, Segment } from 'interlinearizer'; -import { memo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; +import { isWordToken } from './component-types'; import MemoizedPhraseBox from './PhraseBox'; -import MemoizedTokenChip from './TokenChip'; +import { MemoizedInertTokenChip } from './TokenChip'; /** * The two display modes for {@link SegmentView}. @@ -13,57 +14,125 @@ import MemoizedTokenChip from './TokenChip'; */ export type SegmentDisplayMode = 'token-chip' | 'baseline-text'; +/** Props for {@link SegmentView}. */ +type SegmentViewProps = Readonly<{ + /** Controls whether tokens are rendered as chips or as raw baseline text. */ + displayMode: SegmentDisplayMode; + /** Token ref of the word token that should appear focused; `undefined` clears focus. */ + focusedTokenRef: string | undefined; + /** Whether this segment corresponds to the currently active verse. */ + isActive: boolean; + /** + * Called when the segment or one of its word tokens is selected. In `baseline-text` mode the + * whole segment is clickable and `tokenRef` is omitted; in `token-chip` mode only word tokens + * trigger this and `tokenRef` is always provided. + */ + onSelect: (ref: ScriptureRef, tokenRef?: string) => void; + /** The segment to render. */ + segment: Segment; +}>; + /** * Renders a single segment as either inline token chips or plain baseline text. * * @param props - Component props - * @param props.displayMode - Controls how segment content is rendered; defaults to `'token-chip'` + * @param props.displayMode - Controls how segment content is rendered + * @param props.focusedTokenRef - When set, the matching word token's `PhraseBox` is rendered in the + * focused state; only meaningful in `token-chip` mode. * @param props.isActive - Whether this segment is the currently selected verse - * @param props.onClick - Callback invoked when the segment button is clicked + * @param props.onSelect - Required callback invoked when the segment or one of its word tokens is + * interacted with. In `baseline-text` mode the whole segment is clickable and `tokenRef` is + * omitted. In `token-chip` mode only word tokens trigger this callback and `tokenRef` is always + * provided. * @param props.segment - The segment to render - * @returns A button containing the segment's verse label and content + * @returns A button (baseline-text mode) or div (token-chip mode) containing a verse label and + * segment content */ export function SegmentView({ - displayMode = 'token-chip', + displayMode, + focusedTokenRef, isActive, - onClick, + onSelect, segment, -}: Readonly<{ - displayMode?: SegmentDisplayMode; - isActive?: boolean; - onClick?: (ref: ScriptureRef) => void; - segment: Segment; -}>) { +}: SegmentViewProps) { const { book, chapter, verse } = segment.startRef; + const ref: ScriptureRef = useMemo(() => ({ book, chapter, verse }), [book, chapter, verse]); + + /** + * Forwards a token-chip click (identified by its index in `segment.tokens`) to the parent as a + * scripture reference + token id. Stable across renders so `MemoizedPhraseBox` can memoize. + * + * @param index - Index of the clicked token within `segment.tokens`. + */ + const handleTokenClick = useCallback( + (index?: number) => { + if (index !== undefined) onSelect(ref, segment.tokens[index].ref); + }, + [onSelect, ref, segment.tokens], + ); + + /** + * Stable single-token arrays for word tokens keyed by position, so `MemoizedPhraseBox` receives + * the same reference across renders. + */ + const tokenArrays = useMemo( + () => segment.tokens.map((token) => (isWordToken(token) ? [token] : [])), + [segment.tokens], + ); + const sharedClassName = isActive + ? 'tw:w-full tw:rounded tw:border tw:border-border tw:bg-muted/50 tw:p-2' + : 'tw:w-full tw:rounded tw:p-2 tw:transition-colors tw:hover:bg-muted/30'; + + const verseLabel = ( + + {verse} + + ); + + if (displayMode === 'baseline-text') { + return ( + + ); + } + + // Intentional: token-chip mode renders a div, not a button. In this mode individual word tokens + // (via PhraseBox gloss inputs) are the interactive elements, so the outer container does not need + // to be focusable. Keyboard access goes through the gloss inputs inside PhraseBox, not here. return ( - + {verseLabel} + + {segment.tokens.map((token, index) => { + if (!isWordToken(token)) return ; + return ( + + ); + })} + +
); } +/** Memoized version of {@link SegmentView}; use in render-stable segment lists. */ const MemoizedSegmentView = memo(SegmentView); export default MemoizedSegmentView; diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index 430b1776..c613db7d 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -1,25 +1,71 @@ import type { Token } from 'interlinearizer'; -import { memo } from 'react'; +import { memo, useEffect, useState } from 'react'; +import { useGloss, useGlossDispatch } from './AnalysisStore'; /** - * Renders a single token as an inline chip. Word tokens get a bordered box; non-word tokens (e.g. - * punctuation) are rendered as muted inline text. + * Renders a single word token as an inline chip with an editable gloss input below the surface + * text. Gloss value and dispatch are read from {@link AnalysisStoreProvider} context via + * {@link useGloss} and {@link useGlossDispatch}. The gloss is written to the store only on blur, and + * only when the draft differs from the committed value, to avoid creating empty analysis entries on + * focus/blur cycles with no edits. * * @param props - Component props - * @param props.token - The token to render - * @returns A styled inline span + * @param props.token - The word token to render. + * @param props.onFocus - Called when the gloss input receives focus. + * @returns A styled label containing the surface text and a gloss input. */ -export function TokenChip({ token }: Readonly<{ token: Token }>) { - return token.type === 'word' ? ( - - {token.surfaceText} - - ) : ( +export function TokenChip({ + token, + onFocus, +}: Readonly<{ token: Token & { type: 'word' }; onFocus: () => void }>) { + const committedGloss = useGloss(token.ref); + const onGlossChange = useGlossDispatch(); + const [draft, setDraft] = useState(committedGloss); + + // Keep local draft in sync when the committed value changes externally (e.g. project switch). + useEffect(() => { + setDraft(committedGloss); + }, [committedGloss]); + + return ( + + ); +} + +/** + * Renders a non-word token (e.g. punctuation) as muted inline monospace text with no gloss input. + * + * @param props - Component props + * @param props.token - The non-word token to render. + * @returns A muted inline span. + */ +export function InertTokenChip({ token }: Readonly<{ token: Token }>) { + return ( {token.surfaceText} ); } +/** Memoized version of {@link TokenChip}; use in render-stable token lists. */ const MemoizedTokenChip = memo(TokenChip); export default MemoizedTokenChip; + +/** Memoized version of {@link InertTokenChip}; use in render-stable token lists. */ +export const MemoizedInertTokenChip = memo(InertTokenChip); diff --git a/src/components/component-types.ts b/src/components/component-types.ts new file mode 100644 index 00000000..3c59070b --- /dev/null +++ b/src/components/component-types.ts @@ -0,0 +1,11 @@ +import type { Token } from 'interlinearizer'; + +/** + * Narrows a `Token` to a word token. + * + * @param token - The token to test. + * @returns `true` when `token.type === 'word'`. + */ +export function isWordToken(token: Token): token is Token & { type: 'word' } { + return token.type === 'word'; +} diff --git a/src/hooks/useInterlinearizerBookData.ts b/src/hooks/useInterlinearizerBookData.ts index 8ce5dd3d..990a700e 100644 --- a/src/hooks/useInterlinearizerBookData.ts +++ b/src/hooks/useInterlinearizerBookData.ts @@ -8,7 +8,7 @@ import { isPlatformError } from 'platform-bible-utils'; import { useEffect, useMemo } from 'react'; /** Arguments for the {@link useInterlinearizerBookData} hook. */ -interface UseInterlinearizerBookDataArgs { +export interface UseInterlinearizerBookDataArgs { /** PAPI project ID whose USJ book data should be loaded. */ projectId: string; /** Current scripture reference; only `book` and `chapterNum` are used to scope the data. */ @@ -16,7 +16,7 @@ interface UseInterlinearizerBookDataArgs { } /** Return value of the {@link useInterlinearizerBookData} hook. */ -interface UseInterlinearizerBookDataResult { +export interface UseInterlinearizerBookDataResult { /** The fully tokenized book, or `undefined` while loading or on error. */ book: Book | undefined; /** Segments belonging to the current chapter (`scrRef.chapterNum`); empty while loading. */ @@ -69,6 +69,7 @@ export default function useInterlinearizerBookData({ useEffect(() => { if (!tokenizeError) return; + /* v8 ignore next -- isPlatformError branch for writingSystem is unreachable through the mock setup */ const ws = isPlatformError(writingSystem) ? 'und' : writingSystem || 'und'; logger.error('Failed to parse/tokenize USJ book', tokenizeError.raw, { message: tokenizeError.message, diff --git a/src/main.ts b/src/main.ts index c6ab5661..f5428496 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,7 +17,7 @@ import * as projectStorage from './services/projectStorage'; */ const mainWebViewType = 'interlinearizer.mainWebView'; -/** Options passed to `openWebView` when opening the Interlinearizer. */ +/** Options passed to `papi.webViews.openWebView` when opening the Interlinearizer. */ export interface InterlinearizerOpenOptions extends OpenWebViewOptions { /** Paratext project ID to load in the Interlinearizer WebView. */ projectId?: string; diff --git a/src/parsers/papi/usjBookExtractor.ts b/src/parsers/papi/usjBookExtractor.ts index 35b25aed..0340a8a8 100644 --- a/src/parsers/papi/usjBookExtractor.ts +++ b/src/parsers/papi/usjBookExtractor.ts @@ -181,7 +181,10 @@ function handleParaNode(node: UsjNode, state: TraversalState): void { if (node.content) traverse(node.content, state); } -/** Dispatch table mapping USJ node `type` strings to their traversal handlers. */ +/** + * Dispatch table mapping USJ node `type` strings to their traversal handler functions. Node types + * absent from this table (e.g. `char`) are handled generically by recursing into their `content`. + */ const NODE_HANDLERS: Partial void>> = { book: handleBookNode, chapter: handleChapterNode, diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts index a08d9576..e00f003b 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -24,6 +24,8 @@ const projectQueues = new Map>(); * * @param fn - The async function to serialize. * @returns A promise that resolves or rejects with the return value of `fn`. + * @throws Whatever `fn` throws; the queue advances past the error so later operations are not + * blocked. */ function enqueueIndexOp(fn: () => Promise): Promise { const result = indexQueue.then(fn); @@ -38,6 +40,8 @@ function enqueueIndexOp(fn: () => Promise): Promise { * @param id - The project UUID whose queue `fn` should join. * @param fn - The async function to serialize. * @returns A promise that resolves or rejects with the return value of `fn`. + * @throws Whatever `fn` throws; the queue entry is removed and the rejection propagates to the + * caller. */ function enqueueProjectOp(id: string, fn: () => Promise): Promise { const previous = projectQueues.get(id) ?? Promise.resolve(); diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index a64e65b5..262812f0 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -13,6 +13,10 @@ declare module 'papi-shared-types' { 'interlinearizer.continuousScroll': boolean; } + /** + * Command handler signatures contributed by the Interlinearizer extension to the PAPI command + * bus. + */ export interface CommandHandlers { /** * Opens the Interlinearizer for the project associated with the given WebView ID. Called from