diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx index 0266fdd..ba35476 100644 --- a/__mocks__/platform-bible-react.tsx +++ b/__mocks__/platform-bible-react.tsx @@ -82,3 +82,41 @@ export function BookChapterControl({ ); } + +export function Switch({ + checked, + disabled, + id, + onCheckedChange, +}: Readonly<{ + checked?: boolean; + disabled?: boolean; + id?: string; + onCheckedChange?: (checked: boolean) => void; +}>): ReactElement { + return ( + onCheckedChange?.(e.target.checked)} + type="checkbox" + /> + ); +} + +export function Label({ + children, + className, + htmlFor, +}: Readonly<{ + children?: ReactNode; + className?: string; + htmlFor?: string; +}>): ReactElement { + return ( + + ); +} diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 2ebf8ee..afb5f36 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -4,7 +4,11 @@ "en": { "%interlinearizer_dialog_open_title%": "Open Interlinearizer", "%interlinearizer_dialog_open_prompt%": "Choose a project to open in the Interlinearizer:", - "%interlinearizer_openForProject%": "Open Interlinearizer for this Project" + "%interlinearizer_openForProject%": "Open Interlinearizer for this Project", + "%interlinearizer_projectSettings_title%": "Interlinearizer", + "%interlinearizer_projectSettings_continuousScroll%": "Continuous Scroll", + "%interlinearizer_projectSettings_continuousScrollDescription%": "Display tokens in a continuous horizontal scroll strip instead of chapter-segmented rows", + "%interlinearizer_continuousScrollToggle%": "Continuous Scroll" } } } diff --git a/contributions/projectSettings.json b/contributions/projectSettings.json index fe51488..2c3b6bb 100644 --- a/contributions/projectSettings.json +++ b/contributions/projectSettings.json @@ -1 +1,13 @@ -[] +[ + { + "label": "%interlinearizer_projectSettings_title%", + "properties": { + "interlinearizer.continuousScroll": { + "label": "%interlinearizer_projectSettings_continuousScroll%", + "description": "%interlinearizer_projectSettings_continuousScrollDescription%", + "default": true, + "type": "boolean" + } + } + } +] diff --git a/src/__tests__/components/ContinuousScrollToggle.test.tsx b/src/__tests__/components/ContinuousScrollToggle.test.tsx new file mode 100644 index 0000000..34a4558 --- /dev/null +++ b/src/__tests__/components/ContinuousScrollToggle.test.tsx @@ -0,0 +1,30 @@ +/** @file Unit tests for components/ContinuousScrollToggle.tsx. */ +/// +/// + +import { useLocalizedStrings } from '@papi/frontend/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ContinuousScrollToggle from '../../components/ContinuousScrollToggle'; + +describe('ContinuousScrollToggle', () => { + beforeEach(() => { + jest + .mocked(useLocalizedStrings) + .mockReturnValue([ + { '%interlinearizer_continuousScrollToggle%': 'Continuous Scroll' }, + false, + ]); + }); + + it('calls onCheckedChange when toggled', async () => { + const onCheckedChange = jest.fn(); + render(); + const checkbox = screen.getByRole('checkbox'); + + expect(checkbox).toBeChecked(); + await userEvent.click(checkbox); + + expect(onCheckedChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx new file mode 100644 index 0000000..eb19b1a --- /dev/null +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -0,0 +1,818 @@ +/** @file Unit tests for components/ContinuousView.tsx. */ +/// +/// + +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Book } from 'interlinearizer'; +import ContinuousView from '../../components/ContinuousView'; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +/** Factory for a single-chapter book with two segments each having two word tokens. */ +function makeBook(overrides?: Partial): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'In the', + tokens: [ + { + id: 'tok-0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + { + id: 'tok-1', + surfaceText: 'the', + writingSystem: 'en', + type: 'word', + charStart: 3, + charEnd: 6, + }, + ], + }, + { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: 'beginning God', + tokens: [ + { + id: 'tok-2', + surfaceText: 'beginning', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 9, + }, + { + id: 'tok-3', + surfaceText: 'God', + writingSystem: 'en', + type: 'word', + charStart: 10, + charEnd: 13, + }, + ], + }, + ], + ...overrides, + }; +} + +/** A two-chapter book: chapter 1 has one segment, chapter 2 has one segment. */ +function makeTwoChapterBook(): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'Alpha', + tokens: [ + { + id: 'ch1-tok-0', + surfaceText: 'Alpha', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 5, + }, + ], + }, + { + id: 'GEN 2:1', + startRef: { book: 'GEN', chapter: 2, verse: 1 }, + endRef: { book: 'GEN', chapter: 2, verse: 1 }, + baselineText: 'Beta', + tokens: [ + { + id: 'ch2-tok-0', + surfaceText: 'Beta', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 4, + }, + ], + }, + ], + }; +} + +/** A book with exactly one token (minimal edge case). */ +function makeSingleTokenBook(): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'Word', + tokens: [ + { + id: 'tok-only', + surfaceText: 'Word', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 4, + }, + ], + }, + ], + }; +} + +/** + * A book whose GEN 1:1 segment has word tokens and whose GEN 1:2 segment has only a punctuation + * token (no word tokens). Used to exercise code paths that run when a segment exists in the book + * but contributes nothing to phraseEntries / segmentStartIndex. + */ +function makeMixedBook(): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'In the', + tokens: [ + { + id: 'mix-tok-0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + ], + }, + { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: '.', + tokens: [ + { + id: 'mix-punct-0', + surfaceText: '.', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }, + ], + }, + ], + }; +} + +/** A book where every token is non-word, so phraseEntries is empty. */ +function makeWordFreeBook(): Book { + return { + id: 'GEN', + bookRef: 'GEN', + textVersion: '1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: '...', + tokens: [ + { + id: 'wf-punct-0', + surfaceText: '.', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }, + ], + }, + ], + }; +} + +// --------------------------------------------------------------------------- + +const scrollIntoViewMock = jest.fn(); + +beforeAll(() => { + // jsdom does not implement scrollIntoView. + HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; +}); + +beforeEach(() => { + scrollIntoViewMock.mockClear(); +}); + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +describe('ContinuousView rendering', () => { + it('renders all tokens from all segments as a flat list', () => { + render(); + + expect(screen.getByText('In')).toBeInTheDocument(); + expect(screen.getByText('the')).toBeInTheDocument(); + expect(screen.getByText('beginning')).toBeInTheDocument(); + 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(); + + // No verse numbers or colons that would indicate verse labels + expect(screen.queryByText('1:1')).not.toBeInTheDocument(); + expect(screen.queryByText('1:2')).not.toBeInTheDocument(); + // Segment ids should not appear as text + expect(screen.queryByText('GEN 1:1')).not.toBeInTheDocument(); + }); + + it('renders a Previous token button and a Next token button', () => { + render(); + + expect(screen.getByRole('button', { name: 'Previous token' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next token' })).toBeInTheDocument(); + }); + + it('clicking an out-of-focus phrase box brings it into focus', async () => { + render(); + + const clickedToken = screen.getByText('beginning'); + const clickedPhraseBox = clickedToken.closest('[data-phrase-box="true"]'); + if (!clickedPhraseBox) throw new Error('Expected phrase box wrapper for token'); + expect(clickedPhraseBox).toHaveAttribute('data-focus-state', 'default'); + + await userEvent.click(clickedPhraseBox); + + expect(clickedPhraseBox).toHaveAttribute('data-focus-state', 'focused'); + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + }); +}); + +// --------------------------------------------------------------------------- +// Arrow disabled states +// --------------------------------------------------------------------------- + +describe('ContinuousView arrow disabled states', () => { + it('disables the prev arrow on initial render (book start)', () => { + render(); + + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + }); + + it('enables the next arrow on initial render when there are multiple tokens', () => { + render(); + + expect(screen.getByRole('button', { name: 'Next token' })).toBeEnabled(); + }); + + it('disables both arrows when the book has exactly one token', () => { + render(); + + 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(); + + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + }); + + it('disables the next arrow when advanced to the last token', async () => { + render(); + + const nextBtn = screen.getByRole('button', { name: 'Next token' }); + // 4 tokens total: advance 3 times to reach index 3 (last) + await userEvent.click(nextBtn); + await userEvent.click(nextBtn); + await userEvent.click(nextBtn); + + expect(nextBtn).toBeDisabled(); + }); + + it('re-enables the next arrow after going prev from the last token', async () => { + render(); + + const nextBtn = screen.getByRole('button', { name: 'Next token' }); + await userEvent.click(nextBtn); + await userEvent.click(nextBtn); + await userEvent.click(nextBtn); + // Now at end + expect(nextBtn).toBeDisabled(); + + await userEvent.click(screen.getByRole('button', { name: 'Previous token' })); + + expect(nextBtn).toBeEnabled(); + }); +}); + +// --------------------------------------------------------------------------- +// Fade overlays +// --------------------------------------------------------------------------- + +describe('ContinuousView fade overlays', () => { + it('does not render prev fade at book start', () => { + const { container } = render(); + + // Prev fade gradient is tw-from-background (prev-to-next gradient) + const gradients = container.querySelectorAll('[aria-hidden="true"]'); + const prevFades = Array.from(gradients).filter((el) => + el.className.includes('tw-bg-gradient-to-e'), + ); + expect(prevFades).toHaveLength(0); + }); + + it('renders next fade at book start (next side is enabled)', () => { + const { container } = render(); + + const gradients = container.querySelectorAll('[aria-hidden="true"]'); + const nextFades = Array.from(gradients).filter((el) => + el.className.includes('tw-bg-gradient-to-s'), + ); + expect(nextFades).toHaveLength(1); + }); + + it('renders prev fade after moving away from book start', async () => { + const { container } = render(); + + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + const gradients = container.querySelectorAll('[aria-hidden="true"]'); + const prevFades = Array.from(gradients).filter((el) => + el.className.includes('tw-bg-gradient-to-e'), + ); + expect(prevFades).toHaveLength(1); + }); + + it('does not render next fade at book end', async () => { + const { container } = render(); + + const nextBtn = screen.getByRole('button', { name: 'Next token' }); + await userEvent.click(nextBtn); + await userEvent.click(nextBtn); + await userEvent.click(nextBtn); + + const gradients = container.querySelectorAll('[aria-hidden="true"]'); + const nextFades = Array.from(gradients).filter((el) => + el.className.includes('tw-bg-gradient-to-s'), + ); + expect(nextFades).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-chapter traversal +// --------------------------------------------------------------------------- + +describe('ContinuousView cross-chapter traversal', () => { + it('indexes tokens across chapter boundaries in segment order', () => { + render(); + + // Both chapter tokens should be present + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + }); + + it('can navigate across a chapter boundary with the next arrow', async () => { + render(); + + // 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' })); + + // Next arrow should now be disabled (at end = last token = chapter 2 token) + expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); + // Prev arrow should be enabled + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + }); +}); + +// --------------------------------------------------------------------------- +// Smooth-scroll intent +// --------------------------------------------------------------------------- + +describe('ContinuousView smooth-scroll intent', () => { + it('calls scrollIntoView with smooth behaviour when next arrow is clicked', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + expect(scrollIntoViewMock).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'smooth' }), + ); + }); + + it('calls scrollIntoView with smooth behaviour when prev arrow is clicked', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + scrollIntoViewMock.mockClear(); + + await userEvent.click(screen.getByRole('button', { name: 'Previous token' })); + + expect(scrollIntoViewMock).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'smooth' }), + ); + }); + + it('does not call scrollIntoView when a disabled arrow is clicked', async () => { + render(); + scrollIntoViewMock.mockClear(); + + // Prev arrow is disabled at start — clicking it should be a no-op + await userEvent.click(screen.getByRole('button', { name: 'Previous token' })); + + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// activeVerse / verse-jump behaviour +// --------------------------------------------------------------------------- + +describe('ContinuousView activeVerse verse-jump', () => { + // These tests rely on the 500 ms fade-out timer that delays the focus jump. + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('positions at focusIndex 0 when activeVerse matches the first segment', () => { + render( + , + ); + + // At index 0 the prev arrow should be disabled + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + }); + + 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(() => { + jest.advanceTimersByTime(500); + }); + + // focusIndex is now 2 (first token of segment 2), so prev arrow should be enabled + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + }); + + it('jumps across a chapter boundary to the second chapter segment', () => { + const { rerender } = render( + , + ); + + rerender( + , + ); + act(() => { + jest.advanceTimersByTime(500); + }); + + // Chapter 2 starts at index 1 (the last token), so next arrow should be disabled + expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + }); + + it('calls scrollIntoView with instant behaviour when activeVerse changes', () => { + // 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); + }); + + expect(scrollIntoViewMock).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' })); + }); + + it('initializes at the target verse position when activeVerse is provided at mount', () => { + // 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( + , + ); + + // Index 2 is not the start (prev enabled) and not the end (next enabled). + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Next token' })).toBeEnabled(); + }); + + it('does not jump when activeVerse is undefined', () => { + render(); + + // Without activeVerse the strip stays at focusIndex 0 + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + }); + + it('does not jump when activeVerse does not match any segment', () => { + render( + , + ); + + // No matching segment — strip stays at focusIndex 0 + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + }); +}); + +// --------------------------------------------------------------------------- +// onVerseChange outbound propagation +// --------------------------------------------------------------------------- + +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(); + + // Advance twice to reach index 2 (first token of GEN 1:2) + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + expect(handleVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 2 }); + }); + + it('calls onVerseChange when the prev arrow crosses back into a prior verse', async () => { + const handleVerseChange = jest.fn(); + render( + , + ); + handleVerseChange.mockClear(); + + // focusIndex is at 2 (first token of GEN 1:2); go prev to cross back to GEN 1:1 + await userEvent.click(screen.getByRole('button', { name: 'Previous token' })); + + expect(handleVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 1 }); + }); + + it('does not call onVerseChange for multiple arrow clicks within the same verse', async () => { + const handleVerseChange = jest.fn(); + render(); + handleVerseChange.mockClear(); + + // index 0 → index 1: both are in GEN 1:1, no verse change + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + expect(handleVerseChange).not.toHaveBeenCalled(); + }); + + it('does not call onVerseChange when activeVerse prop drives the jump', () => { + // Must advance timers so the jump actually completes and the echo-back guard is exercised. + jest.useFakeTimers(); + const handleVerseChange = jest.fn(); + const { rerender } = render( + , + ); + handleVerseChange.mockClear(); + + rerender( + , + ); + act(() => { + jest.advanceTimersByTime(500); + }); + jest.useRealTimers(); + + expect(handleVerseChange).not.toHaveBeenCalled(); + }); + + it('does not jump back when arrow navigation crosses a verse and the parent echoes activeVerse', async () => { + // Regression: focusPhraseIndex was in the activeVerse effect's dep array, causing it to + // re-run on every arrow press. When crossing a verse, onVerseChange fired, the parent + // updated activeVerse, and the effect jumped back to the old verse — a loop. + const handleVerseChange = jest.fn(); + const { rerender } = render( + , + ); + + // Advance twice from index 0 to index 2 (first token of GEN 1:2). + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + expect(handleVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 2 }); + + // Parent echoes activeVerse = GEN 1:2. The strip is already there — no jump should fire. + rerender( + , + ); + + // Focus stays at index 2: prev arrow enabled, next arrow enabled (not at start or end). + expect(screen.getByRole('button', { name: 'Previous token' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Next token' })).toBeEnabled(); + // onVerseChange must not have been called a second time (no loop). + expect(handleVerseChange).toHaveBeenCalledTimes(1); + }); + + it('keeps clicked phrase focus when activeVerse updates to that same verse', async () => { + const onVerseChange = jest.fn(); + const { rerender } = render( + , + ); + + // Click the second phrase in GEN 1:2 ("God", index 3 in the full strip). + const godToken = screen.getByText('God'); + const godPhraseBox = godToken.closest('[data-phrase-box="true"]'); + if (!godPhraseBox) throw new Error('Expected phrase box wrapper for token'); + await userEvent.click(godPhraseBox); + + // Parent receives verse change and updates activeVerse to GEN 1:2. + expect(onVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 2 }); + rerender( + , + ); + + // If focus was incorrectly reset to the first phrase of the verse ("beginning"), the next + // arrow would be enabled. Staying on "God" keeps us at strip end, so it remains disabled. + expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); + expect(godPhraseBox).toHaveAttribute('data-focus-state', 'focused'); + }); + + it('calls onVerseChange with the chapter-2 verse when crossing the chapter boundary', async () => { + const handleVerseChange = jest.fn(); + render(); + handleVerseChange.mockClear(); + + // ch1 has 1 token (index 0), ch2 starts at index 1 — one click crosses the boundary + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + + expect(handleVerseChange).toHaveBeenCalledWith({ book: 'GEN', chapter: 2, verse: 1 }); + }); + + 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. + await userEvent.click(screen.getByRole('button', { name: 'Next token' })); + handleVerseChange.mockClear(); + + const exoBook: Book = { + ...makeBook(), + id: 'EXO', + bookRef: 'EXO', + segments: makeBook().segments.map((seg) => ({ + ...seg, + startRef: { ...seg.startRef, book: 'EXO' }, + endRef: { ...seg.endRef, book: 'EXO' }, + })), + }; + + rerender(); + + expect(handleVerseChange).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Non-word token rendering, word-free books, and word-free segment jumps +// --------------------------------------------------------------------------- + +describe('ContinuousView non-word tokens and word-free paths', () => { + it('renders a non-word token via TokenChip within the strip', () => { + // makeMixedBook: GEN 1:1 has a word token; GEN 1:2 has a punctuation token + render(); + + // Both the word chip ("In") and the punctuation 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(); + + // No phrase boxes rendered; both arrow buttons disabled (atStart && atEnd when length === 0) + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next token' })).toBeDisabled(); + // The punctuation token itself is rendered + expect(screen.getByText('.')).toBeInTheDocument(); + }); + + it('does not jump when activeVerse targets a segment that has no word tokens', () => { + // Start focused at GEN 1:1 (word token), then move activeVerse to GEN 1:2 (punctuation only). + // getPhraseIndexForVerse should return undefined → no pending jump. + jest.useFakeTimers(); + const { rerender } = render( + , + ); + + rerender( + , + ); + act(() => { + jest.advanceTimersByTime(500); + }); + jest.useRealTimers(); + + // No jump occurred; focus stays at GEN 1:1 (index 0), so prev arrow remains disabled. + expect(screen.getByRole('button', { name: 'Previous token' })).toBeDisabled(); + }); +}); + +// --------------------------------------------------------------------------- +// RTL layout +// --------------------------------------------------------------------------- + +describe('ContinuousView RTL layout', () => { + beforeEach(() => { + document.documentElement.dir = 'rtl'; + }); + + afterEach(() => { + document.documentElement.dir = 'ltr'; + }); + + it('shows right-arrow (→) on the previous button in RTL mode', () => { + render(); + + 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(); + + const nextBtn = screen.getByRole('button', { name: 'Next token' }); + expect(nextBtn.querySelector('[aria-hidden="true"]')).toHaveTextContent('\u2190'); + }); +}); diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx new file mode 100644 index 0000000..cd30bc5 --- /dev/null +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -0,0 +1,251 @@ +/** @file Unit tests for components/Interlinearizer.tsx. */ +/// +/// + +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 Interlinearizer from '../../components/Interlinearizer'; + +// Store captured props so tests can simulate callbacks +let capturedContinuousViewProps: Record = {}; + +jest.mock('../../components/ContinuousView', () => ({ + __esModule: true, + default: (props: Record) => { + capturedContinuousViewProps = props; + 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: [ + { + id: '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: [] }; + +/** Book with two segments in GEN 1 — used by chapter-display tests. */ +const GEN_1_MULTI_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: [ + { + id: 'GEN 1:1:0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + ], + }, + { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: 'And the earth.', + tokens: [ + { + id: 'GEN 1:2:0', + surfaceText: 'And', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 3, + }, + ], + }, + ], +}; + +/** Book with a non-word (punctuation) token — exercises the non-word chip branch. */ +const GEN_1_1_PUNCTUATION_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + segments: [ + { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: '.', + tokens: [ + { + id: 'GEN 1:1:0', + surfaceText: '.', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }, + ], + }, + ], +}; + +function renderInterlinearizer({ + book = GEN_1_1_BOOK, + bookSegments = GEN_1_1_BOOK.segments, + continuousScroll = false, + scrRef = defaultScrRef, + setScrRef = () => {}, +}: { + book?: Book; + bookSegments?: Book['segments']; + continuousScroll?: boolean; + scrRef?: SerializedVerseRef; + setScrRef?: (r: SerializedVerseRef) => void; +} = {}) { + return render( + } + />, + ); +} + +describe('Interlinearizer', () => { + beforeEach(() => { + capturedContinuousViewProps = {}; + }); + + it('renders token chips when the tokenized book has a segment for the current reference', () => { + renderInterlinearizer(); + + expect(screen.getByText('In')).toBeInTheDocument(); + }); + + it('shows a no-verse message when the tokenized book has no segments at all', () => { + renderInterlinearizer({ bookSegments: GEN_EMPTY_BOOK.segments }); + + expect(screen.getByText(/no verse data for gen 1\./i)).toBeInTheDocument(); + }); + + it('renders all segments in the current chapter', () => { + renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments }); + + expect(screen.getByText('In')).toBeInTheDocument(); + expect(screen.getByText('And')).toBeInTheDocument(); + }); + + it('highlights only the segment matching the current verse', () => { + const { container } = renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments }); + + // defaultScrRef is GEN 1:1, so verse 1 is active + const activeSegments = container.querySelectorAll('button[aria-current="true"]'); + expect(activeSegments).toHaveLength(1); + }); + + it('shows all chapter segments when navigating to a title reference (verse 0)', () => { + const titleRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 0 }; + renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, scrRef: titleRef }); + + expect(screen.getByText('In')).toBeInTheDocument(); + expect(screen.getByText('And')).toBeInTheDocument(); + }); + + it('renders non-word tokens as muted chips', () => { + renderInterlinearizer({ bookSegments: GEN_1_1_PUNCTUATION_BOOK.segments }); + + expect(screen.getByText('.')).toBeInTheDocument(); + }); + + it('calls setScrRef with the segment ref when a verse box is clicked', async () => { + const mockSetScrRef = jest.fn(); + renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, setScrRef: mockSetScrRef }); + + await userEvent.click(screen.getByText('And')); + + expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); + }); + + it('renders segments in baseline-text mode when continuousScroll is true', () => { + renderInterlinearizer({ continuousScroll: true }); + + expect(screen.getByText('In the beginning.')).toBeInTheDocument(); + expect(screen.queryByText('In')).not.toBeInTheDocument(); + }); + + it('renders all chapter segments in baseline-text mode when continuousScroll is true', () => { + renderInterlinearizer({ bookSegments: GEN_1_MULTI_BOOK.segments, continuousScroll: true }); + + expect(screen.getByText('In the beginning.')).toBeInTheDocument(); + expect(screen.getByText('And the earth.')).toBeInTheDocument(); + }); + + it('renders ContinuousView when continuousScroll is true', () => { + renderInterlinearizer({ continuousScroll: true }); + + expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); + }); + + it('does not render ContinuousView when continuousScroll is false', () => { + renderInterlinearizer({ continuousScroll: false }); + + expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); + }); + + it('renders ContinuousView above the chapter segment rows when both are present', () => { + const { container } = renderInterlinearizer({ + bookSegments: GEN_1_MULTI_BOOK.segments, + continuousScroll: true, + }); + + const continuousView = screen.getByTestId('continuous-view'); + const allElements = Array.from( + container.querySelectorAll('[data-testid="continuous-view"], button[aria-current]'), + ); + expect(allElements[0]).toBe(continuousView); + }); + + it('calls setScrRef when ContinuousView emits onVerseChange', () => { + const mockSetScrRef = jest.fn(); + renderInterlinearizer({ continuousScroll: true, setScrRef: mockSetScrRef }); + + expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-type-assertion/no-type-assertion + const onVerseChange = capturedContinuousViewProps.onVerseChange as any; + expect(onVerseChange).toBeDefined(); + + onVerseChange({ book: 'GEN', chapter: 2, verse: 3 }); + + expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 2, verseNum: 3 }); + }); +}); diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx new file mode 100644 index 0000000..669fadf --- /dev/null +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -0,0 +1,369 @@ +/** @file Unit tests for components/InterlinearizerLoader.tsx. */ +/// +/// + +import { + useLocalizedStrings, + useProjectData, + useProjectSetting, + useRecentScriptureRefs, +} from '@papi/frontend/react'; +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Book } from 'interlinearizer'; +import { tokenizeBook } from 'parsers/papi/bookTokenizer'; +import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor'; +import InterlinearizerLoader from '../../components/InterlinearizerLoader'; + +jest.mock('parsers/papi/bookTokenizer'); +jest.mock('parsers/papi/usjBookExtractor'); + +jest.mock('../../components/ContinuousView', () => ({ + __esModule: true, + default: () =>
, +})); + +/** + * Matches the PlatformError shape from platform-bible-utils (discriminated by + * platformErrorVersion). + */ +type PlatformError = { platformErrorVersion: number; message: string }; + +const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; +const testProjectId = 'test-project-id'; + +/** 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: [ + { + id: 'GEN 1:1:0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + ], + }, + ], +}; + +/** + * Returns a `useWebViewScrollGroupScrRef` hook stub bound to the given reference and setter. + * + * @param scrRef - Scripture reference to expose; defaults to GEN 1:1 + * @param setScrRef - Setter callback; defaults to a no-op + */ +function makeScrollGroupHook( + scrRef: SerializedVerseRef = defaultScrRef, + setScrRef: (r: SerializedVerseRef) => void = () => {}, +) { + return (): [ + SerializedVerseRef, + (r: SerializedVerseRef) => void, + number | undefined, + (id: number | undefined) => void, + ] => [scrRef, setScrRef, undefined, () => {}]; +} + +/** Configures useProjectData to return the given BookUSJ value and loading state this render. */ +function mockBookData(value: unknown, isLoading = false): void { + jest.mocked(useProjectData).mockImplementation(() => ({ + BookUSJ: () => [value, jest.fn(), isLoading], + })); +} + +/** + * Configures useProjectSetting for the languageTag and continuousScroll keys. All other keys + * receive their defaultState. + * + * @param tag - Writing system tag returned for `platform.languageTag` + * @param continuousScroll - Value returned for `interlinearizer.continuousScroll`; defaults to + * `false` so existing token-chip rendering tests are unaffected + */ +function mockWritingSystem(tag: string | PlatformError = 'en', continuousScroll = false): void { + jest.mocked(useProjectSetting).mockImplementation((_projectId, key, defaultState) => { + if (key === 'platform.languageTag') return [tag, jest.fn(), jest.fn(), false]; + if (key === 'interlinearizer.continuousScroll') + return [continuousScroll, jest.fn(), jest.fn(), false]; + return [defaultState, jest.fn(), jest.fn(), false]; + }); +} + +describe('InterlinearizerLoader', () => { + beforeEach(() => { + mockBookData(undefined); + mockWritingSystem(); + jest.mocked(useLocalizedStrings).mockReturnValue([{}, false]); + jest.mocked(useRecentScriptureRefs).mockReturnValue({ + recentScriptureRefs: [], + addRecentScriptureRef: jest.fn(), + }); + jest.mocked(extractBookFromUsj).mockReturnValue({ + bookCode: 'GEN', + writingSystem: 'en', + contentHash: 'abc', + verses: [], + }); + jest.mocked(tokenizeBook).mockReturnValue(GEN_1_1_BOOK); + }); + + it('shows the book chapter control and renders a segment when book data is available', () => { + mockBookData({}); + render( + , + ); + + expect(screen.getByTestId('book-chapter-control')).toBeInTheDocument(); + expect(screen.getByText('In')).toBeInTheDocument(); + }); + + it('shows Loading when book data has not arrived', () => { + mockBookData(undefined, true); + render( + , + ); + + expect(screen.getByText('Loading…')).toBeInTheDocument(); + }); + + it('shows an error when no USJ book is available for the project', () => { + mockBookData(undefined, false); + render( + , + ); + + expect(screen.getByRole('heading', { name: /error loading book/i })).toBeInTheDocument(); + expect(screen.getByText(/no usj book available for gen in project/i)).toBeInTheDocument(); + }); + + it('shows an error heading and message when book data is a PlatformError', () => { + mockBookData({ platformErrorVersion: 1, message: 'Project not found' }); + render( + , + ); + + expect(screen.getByRole('heading', { name: /error loading book/i })).toBeInTheDocument(); + expect(screen.getByText(/project not found/i)).toBeInTheDocument(); + }); + + it('falls back to "und" writing system when useProjectSetting returns a PlatformError', () => { + mockBookData({}); + mockWritingSystem({ platformErrorVersion: 1, message: 'Setting unavailable' }); + render( + , + ); + + expect(screen.getByText('In')).toBeInTheDocument(); + expect(extractBookFromUsj).toHaveBeenCalledWith(expect.anything(), 'und'); + }); + + it('falls back to "und" writing system when useProjectSetting returns an empty string', () => { + mockBookData({}); + mockWritingSystem(''); + render( + , + ); + + expect(screen.getByText('In')).toBeInTheDocument(); + expect(extractBookFromUsj).toHaveBeenCalledWith(expect.anything(), 'und'); + }); + + it('shows an error heading and message when tokenization throws an Error', () => { + mockBookData({}); + jest.mocked(tokenizeBook).mockImplementation(() => { + throw new Error('parse failure'); + }); + render( + , + ); + + expect(screen.getByRole('heading', { name: /error processing book/i })).toBeInTheDocument(); + expect(screen.getByText('parse failure')).toBeInTheDocument(); + }); + + it('shows an error message when tokenization throws a non-Error value', () => { + mockBookData({}); + jest.mocked(tokenizeBook).mockImplementation(() => { + // eslint-disable-next-line no-throw-literal + throw 'unexpected string error'; + }); + render( + , + ); + + expect(screen.getByRole('heading', { name: /error processing book/i })).toBeInTheDocument(); + expect(screen.getByText('unexpected string error')).toBeInTheDocument(); + }); + + it('passes a book-stable ref to BookUSJ so chapter and verse changes do not re-fetch the book', () => { + const mockBookUSJ = jest.fn().mockReturnValue([{}, jest.fn(), false]); + jest.mocked(useProjectData).mockImplementation(() => ({ BookUSJ: mockBookUSJ })); + const { rerender } = render( + , + ); + rerender( + , + ); + + const refsPassed = mockBookUSJ.mock.calls.map((c) => c[0]); + refsPassed.forEach((ref) => expect(ref).toEqual({ book: 'GEN', chapterNum: 1, verseNum: 1 })); + expect(mockBookUSJ.mock.calls.length).toBeGreaterThanOrEqual(2); + refsPassed.slice(1).forEach((ref) => expect(ref).toBe(refsPassed[0])); + }); + + it('renders the continuous scroll toggle', () => { + mockBookData({}); + render( + , + ); + + expect(screen.getByRole('checkbox')).toBeInTheDocument(); + }); + + it('continuous scroll toggle is checked when the setting is true', () => { + mockBookData({}); + mockWritingSystem('en', true); + render( + , + ); + + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('continuous scroll toggle is unchecked when the setting is false', () => { + mockBookData({}); + render( + , + ); + + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('clicking the continuous scroll toggle calls setContinuousScroll with the new value', async () => { + mockBookData({}); + const mockSetContinuousScroll = jest.fn(); + jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { + if (key === 'interlinearizer.continuousScroll') + return [true, mockSetContinuousScroll, jest.fn(), false]; + if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; + return [d, jest.fn(), jest.fn(), false]; + }); + render( + , + ); + + await userEvent.click(screen.getByRole('checkbox')); + + expect(mockSetContinuousScroll).toHaveBeenCalledWith(false); + }); + + it('switches rendering immediately using optimistic local state while setting saves', async () => { + mockBookData({}); + const mockSetContinuousScroll = jest.fn(); + // Setting source remains true during the test (simulates delayed persistence confirmation). + jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { + if (key === 'interlinearizer.continuousScroll') + return [true, mockSetContinuousScroll, jest.fn(), false]; + if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; + return [d, jest.fn(), jest.fn(), false]; + }); + render( + , + ); + + // Initially in continuous mode. + expect(screen.getByTestId('continuous-view')).toBeInTheDocument(); + expect(screen.queryByText('In')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('checkbox')); + + // Before setting saves, UI should already switch to token-chip mode. + expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); + expect(screen.getByText('In')).toBeInTheDocument(); + expect(mockSetContinuousScroll).toHaveBeenCalledWith(false); + }); + + it('toggles continuous scroll setting back to true after being false', async () => { + mockBookData({}); + const mockSetContinuousScroll = jest.fn(); + jest.mocked(useProjectSetting).mockImplementation((_p, key, d) => { + if (key === 'interlinearizer.continuousScroll') + return [false, mockSetContinuousScroll, jest.fn(), false]; + if (key === 'platform.languageTag') return ['en', jest.fn(), jest.fn(), false]; + return [d, jest.fn(), jest.fn(), false]; + }); + render( + , + ); + + // Initially in token-chip mode + expect(screen.queryByTestId('continuous-view')).not.toBeInTheDocument(); + + // Click toggle to turn on continuous mode + await userEvent.click(screen.getByRole('checkbox')); + expect(mockSetContinuousScroll).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/__tests__/components/PhraseBox.test.tsx b/src/__tests__/components/PhraseBox.test.tsx new file mode 100644 index 0000000..42b21a5 --- /dev/null +++ b/src/__tests__/components/PhraseBox.test.tsx @@ -0,0 +1,134 @@ +/** @file Unit tests for PhraseBox component. */ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Token } from 'interlinearizer'; +import { PhraseBox } from '../../components/PhraseBox'; + +jest.mock('../../components/TokenChip', () => ({ + __esModule: true, + default: ({ token }: { token: Token }) => ( + {token.surfaceText} + ), +})); + +/** Pre-built test token */ +const TEST_TOKEN: Token = { + id: 'token-1', + surfaceText: 'Hello', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 5, +}; + +/** Second test token */ +const TEST_TOKEN_2: Token = { + id: 'token-2', + surfaceText: 'World', + writingSystem: 'en', + type: 'word', + charStart: 6, + charEnd: 11, +}; + +describe('PhraseBox', () => { + it('renders as a span when no onClick handler is provided', () => { + render(); + + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + expect(phraseBox?.tagName).toBe('SPAN'); + }); + + it('renders as a button when onClick handler is provided', () => { + const mockOnClick = jest.fn(); + render(); + + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + expect(phraseBox?.tagName).toBe('BUTTON'); + expect(phraseBox).toHaveAttribute('type', 'button'); + }); + + it('renders tokens using TokenChip components', () => { + 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(); + + const button = screen.getByRole('button'); + await userEvent.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('applies focused styling 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'); + }); + + it('applies default styling 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'); + }); + + it('applies default styling when isFocused is not provided', () => { + render(); + + const phraseBox = document.querySelector('[data-phrase-box="true"]'); + expect(phraseBox).toHaveAttribute('data-focus-state', 'default'); + }); + + it('button has correct focused styling and cursor', () => { + const mockOnClick = jest.fn(); + 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'); + }); + + it('button has hover styling', () => { + const mockOnClick = jest.fn(); + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('hover:tw-bg-muted/30'); + }); + + it('renders multiple tokens in order', () => { + 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'); + }); + + it('applies base spacing classes to both button and span', () => { + const { rerender } = render(); + + const span = document.querySelector('[data-phrase-box="true"]'); + expect(span).toHaveClass('tw-px-1'); + expect(span).toHaveClass('tw-py-0.5'); + + const mockOnClick = jest.fn(); + rerender(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('tw-px-1'); + expect(button).toHaveClass('tw-py-0.5'); + }); +}); diff --git a/src/__tests__/components/ScriptureNavControls.test.tsx b/src/__tests__/components/ScriptureNavControls.test.tsx new file mode 100644 index 0000000..a7c2bd7 --- /dev/null +++ b/src/__tests__/components/ScriptureNavControls.test.tsx @@ -0,0 +1,56 @@ +/** @file Unit tests for components/ScriptureNavControls.tsx. */ +/// +/// + +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useLocalizedStrings, useRecentScriptureRefs } from '@papi/frontend/react'; +import ScriptureNavControls from '../../components/ScriptureNavControls'; + +const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + +describe('ScriptureNavControls', () => { + beforeEach(() => { + jest.mocked(useLocalizedStrings).mockReturnValue([{}, false]); + jest.mocked(useRecentScriptureRefs).mockReturnValue({ + recentScriptureRefs: [], + addRecentScriptureRef: jest.fn(), + }); + }); + + it('shows the book chapter control', () => { + render( + {}} + scrollGroupId={undefined} + onChangeScrollGroupId={() => {}} + />, + ); + + expect(screen.getByTestId('book-chapter-control')).toBeInTheDocument(); + }); + + it('calls handleSubmit and addRecentScriptureRef when the verse picker submits', async () => { + const mockHandleSubmit = jest.fn(); + const mockAddRecentRef = jest.fn(); + jest.mocked(useRecentScriptureRefs).mockReturnValue({ + recentScriptureRefs: [], + addRecentScriptureRef: mockAddRecentRef, + }); + render( + {}} + />, + ); + + await userEvent.click(screen.getByRole('button', { name: /submit reference/i })); + + expect(mockHandleSubmit).toHaveBeenCalledWith(defaultScrRef); + expect(mockAddRecentRef).toHaveBeenCalledWith(defaultScrRef); + }); +}); diff --git a/src/__tests__/components/SegmentView.test.tsx b/src/__tests__/components/SegmentView.test.tsx new file mode 100644 index 0000000..efaaa84 --- /dev/null +++ b/src/__tests__/components/SegmentView.test.tsx @@ -0,0 +1,119 @@ +/** @file Unit tests for components/SegmentView.tsx. */ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Segment } from 'interlinearizer'; +import { SegmentView } from '../../components/SegmentView'; + +/** A word token segment. */ +const WORD_SEGMENT: Segment = { + id: 'GEN 1:1', + startRef: { book: 'GEN', chapter: 1, verse: 1 }, + endRef: { book: 'GEN', chapter: 1, verse: 1 }, + baselineText: 'In the beginning.', + tokens: [ + { id: 'tok-0', surfaceText: 'In', writingSystem: 'en', type: 'word', charStart: 0, charEnd: 2 }, + { + id: 'tok-1', + surfaceText: 'the', + writingSystem: 'en', + type: 'word', + charStart: 3, + charEnd: 6, + }, + ], +}; + +/** A segment with a single punctuation (non-word) token. */ +const PUNCT_SEGMENT: Segment = { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: '.', + tokens: [ + { + id: 'tok-p', + surfaceText: '.', + writingSystem: 'en', + type: 'punctuation', + charStart: 0, + charEnd: 1, + }, + ], +}; + +describe('SegmentView', () => { + it('renders word token chips in token-chip mode (default)', () => { + render(); + + expect(screen.getByText('In')).toBeInTheDocument(); + expect(screen.getByText('the')).toBeInTheDocument(); + }); + + it('renders non-word (punctuation) tokens in token-chip mode', () => { + render(); + + expect(screen.getByText('.')).toBeInTheDocument(); + }); + + it('renders explicit token-chip mode the same as the default', () => { + render(); + + expect(screen.getByText('In')).toBeInTheDocument(); + }); + + it('renders baselineText in baseline-text mode', () => { + render(); + + expect(screen.getByText('In the beginning.')).toBeInTheDocument(); + }); + + it('does not render individual tokens in baseline-text mode', () => { + render(); + + expect(screen.queryByText('In')).not.toBeInTheDocument(); + expect(screen.queryByText('the')).not.toBeInTheDocument(); + }); + + it('shows the verse number label', () => { + render(); + + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('sets aria-current="true" when isActive is true', () => { + render(); + + expect(screen.getByRole('button')).toHaveAttribute('aria-current', 'true'); + }); + + it('does not set aria-current when isActive is false', () => { + render(); + + expect(screen.getByRole('button')).not.toHaveAttribute('aria-current'); + }); + + it('does not set aria-current when isActive is omitted', () => { + render(); + + expect(screen.getByRole('button')).not.toHaveAttribute('aria-current'); + }); + + it('calls onClick when the button is clicked', async () => { + const handleClick = jest.fn(); + render(); + + await userEvent.click(screen.getByRole('button')); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('does not throw when onClick is omitted and button is clicked', async () => { + render(); + + await userEvent.click(screen.getByRole('button')); + // No assertion needed — test passes if no error is thrown + }); +}); diff --git a/src/__tests__/hooks/useInterlinearizerBookData.test.ts b/src/__tests__/hooks/useInterlinearizerBookData.test.ts new file mode 100644 index 0000000..51d00e0 --- /dev/null +++ b/src/__tests__/hooks/useInterlinearizerBookData.test.ts @@ -0,0 +1,322 @@ +/** @file Unit tests for useInterlinearizerBookData hook. */ +/// + +import { logger } from '@papi/frontend'; +import { useProjectData, useProjectSetting } from '@papi/frontend/react'; +import { renderHook } from '@testing-library/react'; +import type { Book } from 'interlinearizer'; +import { tokenizeBook } from 'parsers/papi/bookTokenizer'; +import { extractBookFromUsj, type RawBook } from 'parsers/papi/usjBookExtractor'; +import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData'; + +jest.mock('parsers/papi/bookTokenizer'); +jest.mock('parsers/papi/usjBookExtractor'); + +/** Mock PlatformError shape */ +type PlatformError = { message: string; platformErrorVersion: number }; + +/** Pre-built RawBook for mocking extractBookFromUsj return value */ +const TEST_RAW_BOOK: RawBook = { + bookCode: 'GEN', + writingSystem: 'en', + contentHash: 'test-hash', + verses: [ + { sid: 'GEN 1:1', text: 'In the beginning.' }, + { sid: 'GEN 1:2', text: 'And the earth.' }, + { sid: 'GEN 2:1', text: 'The second day.' }, + ], +}; + +/** Pre-built Book for mocking tokenizeBook return value */ +const TEST_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: [ + { + id: 'GEN 1:1:0', + surfaceText: 'In', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 2, + }, + ], + }, + { + id: 'GEN 1:2', + startRef: { book: 'GEN', chapter: 1, verse: 2 }, + endRef: { book: 'GEN', chapter: 1, verse: 2 }, + baselineText: 'And the earth.', + tokens: [ + { + id: 'GEN 1:2:0', + surfaceText: 'And', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 3, + }, + ], + }, + { + id: 'GEN 2:1', + startRef: { book: 'GEN', chapter: 2, verse: 1 }, + endRef: { book: 'GEN', chapter: 2, verse: 1 }, + baselineText: 'The second day.', + tokens: [ + { + id: 'GEN 2:1:0', + surfaceText: 'The', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 3, + }, + ], + }, + ], +}; + +const GEN_1_1_SRC_REF = { book: 'GEN', chapterNum: 1, verseNum: 1 }; + +describe('useInterlinearizerBookData', () => { + /** Default mock setup for useProjectData */ + const setupDefaultProjectDataMock = () => { + jest.mocked(useProjectData).mockReturnValue({ + BookUSJ: () => [{ USJ: 'mock-usj' }, jest.fn(), false], + }); + }; + + /** Default mock setup for useProjectSetting */ + const setupDefaultProjectSettingMock = () => { + jest.mocked(useProjectSetting).mockReturnValue(['en', jest.fn(), jest.fn(), false]); + }; + + beforeAll(() => { + setupDefaultProjectDataMock(); + setupDefaultProjectSettingMock(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(logger.error).mockImplementation(() => {}); + setupDefaultProjectDataMock(); + setupDefaultProjectSettingMock(); + }); + + it('returns book data when project is set and data loads successfully', () => { + jest.mocked(useProjectData).mockReturnValue({ BookUSJ: () => [undefined, jest.fn(), true] }); + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBeUndefined(); + expect(result.current.isLoading).toBe(true); + expect(result.current.bookError).toBeUndefined(); + }); + + it('returns error when USJ book data is a PlatformError', () => { + const platformError: PlatformError = { message: 'Project not found', platformErrorVersion: 1 }; + jest.mocked(useProjectData).mockReturnValue({ + BookUSJ: () => [platformError, jest.fn(), false], + }); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBeUndefined(); + expect(result.current.bookError).toBe('Project not found'); + expect(result.current.tokenizeError).toBeUndefined(); + }); + + it('returns error when USJ book is unavailable', () => { + jest.mocked(useProjectData).mockReturnValue({ + BookUSJ: () => [undefined, jest.fn(), false], + }); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBeUndefined(); + expect(result.current.bookError).toContain('No USJ book available'); + }); + + it('returns tokenization error when extractBookFromUsj throws', () => { + const error = new Error('Invalid USJ format'); + jest.mocked(extractBookFromUsj).mockImplementation(() => { + throw error; + }); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBeUndefined(); + expect(result.current.tokenizeError?.message).toBe('Invalid USJ format'); + expect(result.current.tokenizeError?.raw).toBe(error); + }); + + it('returns tokenization error when tokenizeBook throws non-Error', () => { + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + + const nonErrorValue = 'some string error'; + jest.mocked(tokenizeBook).mockImplementation(() => { + throw nonErrorValue; + }); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBeUndefined(); + expect(result.current.tokenizeError?.message).toBe('some string error'); + expect(result.current.tokenizeError?.raw).toBe(nonErrorValue); + }); + + it('filters segments to current chapter', () => { + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + jest.mocked(tokenizeBook).mockReturnValue(TEST_BOOK); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.chapterSegments).toHaveLength(2); // Only GEN 1:1 and GEN 1:2 + expect(result.current.chapterSegments[0].id).toBe('GEN 1:1'); + expect(result.current.chapterSegments[1].id).toBe('GEN 1:2'); + }); + + it('filters segments for different chapters correctly', () => { + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + jest.mocked(tokenizeBook).mockReturnValue(TEST_BOOK); + + const { result } = renderHook(() => + useInterlinearizerBookData({ + projectId: 'test-project', + scrRef: { book: 'GEN', chapterNum: 2, verseNum: 1 }, + }), + ); + + expect(result.current.chapterSegments).toHaveLength(1); // Only GEN 2:1 + expect(result.current.chapterSegments[0].id).toBe('GEN 2:1'); + }); + + it('falls back to "und" writing system when useProjectSetting returns PlatformError', () => { + const platformError: PlatformError = { + message: 'Setting unavailable', + platformErrorVersion: 1, + }; + jest.mocked(useProjectSetting).mockReturnValue([platformError, jest.fn(), jest.fn(), false]); + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + jest.mocked(tokenizeBook).mockReturnValue(TEST_BOOK); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBe(TEST_BOOK); + expect(jest.mocked(extractBookFromUsj)).toHaveBeenCalledWith({ USJ: 'mock-usj' }, 'und'); + }); + + it('falls back to "und" writing system when useProjectSetting returns empty string', () => { + jest.mocked(useProjectSetting).mockReturnValue(['', jest.fn(), jest.fn(), false]); + jest.mocked(extractBookFromUsj).mockReturnValue(TEST_RAW_BOOK); + jest.mocked(tokenizeBook).mockReturnValue(TEST_BOOK); + + const { result } = renderHook(() => + useInterlinearizerBookData({ projectId: 'test-project', scrRef: { ...GEN_1_1_SRC_REF } }), + ); + + expect(result.current.book).toBe(TEST_BOOK); + expect(jest.mocked(extractBookFromUsj)).toHaveBeenCalledWith({ USJ: 'mock-usj' }, 'und'); + }); + + it('logs tokenization error when hook has projectId and tokenizeError occurs', () => { + 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: 'en', + }, + ); + }); + + it('logs tokenization error with PlatformError writing system', () => { + const platformError: PlatformError = { + message: 'Setting unavailable', + platformErrorVersion: 1, + }; + jest.mocked(useProjectSetting).mockReturnValue([platformError, 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', + }, + ); + }); + + 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__/hooks/useOptimisticBooleanSetting.test.ts b/src/__tests__/hooks/useOptimisticBooleanSetting.test.ts new file mode 100644 index 0000000..ca2fe70 --- /dev/null +++ b/src/__tests__/hooks/useOptimisticBooleanSetting.test.ts @@ -0,0 +1,171 @@ +/** @file Unit tests for useOptimisticBooleanSetting hook. */ +/// + +import { useProjectSetting } from '@papi/frontend/react'; +import { act, renderHook } from '@testing-library/react'; +import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting'; + +const mockUseProjectSetting = jest.mocked(useProjectSetting); + +const SETTING_KEY = 'interlinearizer.continuousScroll' as const; +const TIMEOUT_MS = 15_000; + +describe('useOptimisticBooleanSetting', () => { + let mockSetSetting: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + mockSetSetting = jest.fn(); + mockUseProjectSetting.mockReturnValue([false, mockSetSetting, jest.fn(), false]); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the persisted setting value as the initial display value', () => { + mockUseProjectSetting.mockReturnValue([true, mockSetSetting, jest.fn(), false]); + const { result } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, false), + ); + expect(result.current.value).toBe(true); + }); + + it('falls back to defaultValue when the persisted setting is not yet a boolean', () => { + mockUseProjectSetting.mockReturnValue([undefined, mockSetSetting, jest.fn(), false]); + const { result } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, true), + ); + expect(result.current.value).toBe(true); + }); + + it('updates the display value immediately on change (optimistic update)', () => { + const { result } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, false), + ); + act(() => { + result.current.onChange(true); + }); + expect(result.current.value).toBe(true); + }); + + it('calls setSetting with the new value on change', () => { + const { result } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, false), + ); + act(() => { + result.current.onChange(true); + }); + expect(mockSetSetting).toHaveBeenCalledWith(true); + }); + + it('ignores incoming setting updates while the timeout is active', () => { + // Setting not yet loaded so `setting` starts as non-boolean. + mockUseProjectSetting.mockReturnValue([undefined, mockSetSetting, jest.fn(), false]); + const { result, rerender } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, true), + ); + + act(() => { + result.current.onChange(false); + }); + expect(result.current.value).toBe(false); + + // Simulate the store returning a conflicting value during the lock period. + mockUseProjectSetting.mockReturnValue([true, mockSetSetting, jest.fn(), false]); + rerender(); + + // Value should remain at the optimistically set value. + expect(result.current.value).toBe(false); + }); + + it('accepts incoming setting updates after the timeout elapses (covers lines 57-58)', () => { + // Setting not yet loaded so `setting` starts as non-boolean. + mockUseProjectSetting.mockReturnValue([undefined, mockSetSetting, jest.fn(), false]); + const { result, rerender } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, true), + ); + + // Make an optimistic change and let the store return a conflicting value. + act(() => { + result.current.onChange(false); + }); + mockUseProjectSetting.mockReturnValue([true, mockSetSetting, jest.fn(), false]); + rerender(); + expect(result.current.value).toBe(false); // Locked to optimistic value. + + // Fire the timeout — executes lines 57-58 (timeoutRef.current = undefined; ignoreRef.current = false). + act(() => { + jest.advanceTimersByTime(TIMEOUT_MS); + }); + + // The store value (true) should now be accepted when setting changes. + mockUseProjectSetting.mockReturnValue([false, mockSetSetting, jest.fn(), false]); + rerender(); + expect(result.current.value).toBe(false); + }); + + it('clears the first timeout when onChange is called a second time', () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + const { result } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, false), + ); + + act(() => { + result.current.onChange(true); + }); + act(() => { + result.current.onChange(false); // second call should clear the first timer + }); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it('does not accept setting updates until the reset timeout elapses after back-to-back onChange calls', () => { + // Start with a non-boolean setting so the initial useEffect bails early. + mockUseProjectSetting.mockReturnValue([undefined, mockSetSetting, jest.fn(), false]); + const { result, rerender } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, false), + ); + + act(() => { + result.current.onChange(true); // timer A starts at t=0 + }); + act(() => { + jest.advanceTimersByTime(TIMEOUT_MS - 1_000); // t=14000 + }); + act(() => { + result.current.onChange(true); // timer A cleared, timer B starts at t=14000 + }); + + // At t=15000, timer A would have fired but was cleared — lock still active. + act(() => { + jest.advanceTimersByTime(1_000); + }); + // Arriving setting is ignored while locked. + mockUseProjectSetting.mockReturnValue([false, mockSetSetting, jest.fn(), false]); + rerender(); + expect(result.current.value).toBe(true); // locked; update blocked + + // Timer B fires at t=29000 — lock released. + act(() => { + jest.advanceTimersByTime(TIMEOUT_MS - 1_000); + }); + // New setting value (different from the false that arrived during the lock) triggers the effect. + mockUseProjectSetting.mockReturnValue([true, mockSetSetting, jest.fn(), false]); + rerender(); + expect(result.current.value).toBe(true); // lock released; setting accepted + }); + + it('clears the pending timeout on unmount', () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + const { result, unmount } = renderHook(() => + useOptimisticBooleanSetting('project-1', SETTING_KEY, false), + ); + act(() => { + result.current.onChange(true); + }); + unmount(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/interlinearizer.web-view.test.tsx b/src/__tests__/interlinearizer.web-view.test.tsx index fd6f65f..daac793 100644 --- a/src/__tests__/interlinearizer.web-view.test.tsx +++ b/src/__tests__/interlinearizer.web-view.test.tsx @@ -5,25 +5,13 @@ import type { WebViewProps } from '@papi/core'; import type { SerializedVerseRef } from '@sillsdev/scripture'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { - useLocalizedStrings, - useProjectData, - useProjectSetting, - useRecentScriptureRefs, -} from '@papi/frontend/react'; -import type { Book } from 'interlinearizer'; -import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor'; -import { tokenizeBook } from 'parsers/papi/bookTokenizer'; -jest.mock('parsers/papi/bookTokenizer'); -jest.mock('parsers/papi/usjBookExtractor'); - -/** - * Matches the PlatformError shape from platform-bible-utils (discriminated by - * platformErrorVersion). - */ -type PlatformError = { platformErrorVersion: number; message: string }; +jest.mock('../components/InterlinearizerLoader', () => ({ + __esModule: true, + default: ({ projectId }: { projectId: string }) => ( +
Loader for {projectId}
+ ), +})); /** * Load the WebView module; it assigns the component to globalThis.webViewComponent. This pattern is @@ -40,108 +28,8 @@ if (!InterlinearizerWebView) throw new Error('webViewComponent not loaded'); /** Minimal SerializedVerseRef for hook mock return. */ const defaultScrRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 1 }; -const testProjectId = 'test-project-id'; - -/** Pre-built Book with one GEN 1:1 segment — used by tests that need the strip to render. */ -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: [ - { - id: '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: [] }; - -/** Book with two segments in GEN 1 — used by chapter-display tests. */ -const GEN_1_MULTI_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: [ - { - id: 'GEN 1:1:0', - surfaceText: 'In', - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 2, - }, - ], - }, - { - id: 'GEN 1:2', - startRef: { book: 'GEN', chapter: 1, verse: 2 }, - endRef: { book: 'GEN', chapter: 1, verse: 2 }, - baselineText: 'And the earth.', - tokens: [ - { - id: 'GEN 1:2:0', - surfaceText: 'And', - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 3, - }, - ], - }, - ], -}; - -/** Book with a non-word (punctuation) token — exercises the non-word chip branch. */ -const GEN_1_1_PUNCTUATION_BOOK: Book = { - id: 'GEN', - bookRef: 'GEN', - textVersion: 'v1', - segments: [ - { - id: 'GEN 1:1', - startRef: { book: 'GEN', chapter: 1, verse: 1 }, - endRef: { book: 'GEN', chapter: 1, verse: 1 }, - baselineText: '.', - tokens: [ - { - id: 'GEN 1:1:0', - surfaceText: '.', - writingSystem: 'en', - type: 'punctuation', - charStart: 0, - charEnd: 1, - }, - ], - }, - ], -}; - /** Builds a minimal WebViewProps for tests. */ -function makeProps( - projectId?: string, - scrRef: SerializedVerseRef = defaultScrRef, - setScrRef: (r: SerializedVerseRef) => void = () => {}, -): WebViewProps { +function makeProps(projectId?: string, scrRef: SerializedVerseRef = defaultScrRef): WebViewProps { return { id: 'test-id', webViewType: 'interlinearizer.mainWebView', @@ -156,218 +44,21 @@ function makeProps( (r: SerializedVerseRef) => void, number | undefined, (id: number | undefined) => void, - ] => [scrRef, setScrRef, undefined, () => {}], + ] => [scrRef, () => {}, undefined, () => {}], updateWebViewDefinition: () => true, }; } -/** Configures useProjectData to return the given BookUSJ value and loading state this render. */ -function mockBookData(value: unknown, isLoading = false): void { - jest.mocked(useProjectData).mockImplementation(() => ({ - BookUSJ: () => [value, jest.fn(), isLoading], - })); -} - -/** Configures useProjectSetting to return the given writing system tag. */ -function mockWritingSystem(tag: string | PlatformError = 'en'): void { - jest.mocked(useProjectSetting).mockReturnValue([tag, jest.fn(), jest.fn(), false]); -} - describe('InterlinearizerWebView', () => { - beforeEach(() => { - mockBookData(undefined); - mockWritingSystem(); - jest.mocked(useLocalizedStrings).mockReturnValue([{}, false]); - jest.mocked(useRecentScriptureRefs).mockReturnValue({ - recentScriptureRefs: [], - addRecentScriptureRef: jest.fn(), - }); - jest.mocked(extractBookFromUsj).mockReturnValue({ - bookCode: 'GEN', - writingSystem: 'en', - contentHash: 'abc', - verses: [], - }); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_1_BOOK); - }); - - it('shows the book chapter control regardless of whether a project is linked', () => { - render(); - - expect(screen.getByTestId('book-chapter-control')).toBeInTheDocument(); - }); - it('shows a prompt to open from a project when no projectId is provided', () => { render(); expect(screen.getByText(/open this webview from a paratext project/i)).toBeInTheDocument(); }); - it('shows the book chapter control and renders a segment when a project is linked', () => { - mockBookData({}); - render(); - - expect(screen.getByTestId('book-chapter-control')).toBeInTheDocument(); - expect(screen.getByText('In')).toBeInTheDocument(); - }); - - it('shows Loading when projectId is set but book data has not arrived', () => { - mockBookData(undefined, true); - render(); - - expect(screen.getByText('Loading…')).toBeInTheDocument(); - }); - - it('shows an error when no USJ book is available for the project', () => { - mockBookData(undefined, false); - render(); - - expect(screen.getByRole('heading', { name: /error loading book/i })).toBeInTheDocument(); - expect(screen.getByText(/no usj book available for gen in project/i)).toBeInTheDocument(); - }); - - it('renders token chips when the tokenized book has a segment for the current reference', () => { - mockBookData({}); - render(); - - expect(screen.getByText('In')).toBeInTheDocument(); - }); - - it('shows a no-verse message when the tokenized book has no segments at all', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_EMPTY_BOOK); - render(); - - expect(screen.getByText(/no verse data for gen 1\./i)).toBeInTheDocument(); - }); - - it('renders all segments in the current chapter', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); - render(); - - expect(screen.getByText('In')).toBeInTheDocument(); - expect(screen.getByText('And')).toBeInTheDocument(); - }); - - it('highlights only the segment matching the current verse', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); - // defaultScrRef is GEN 1:1, so verse 1 is active - const { container } = render(); - - const activeSegments = container.querySelectorAll('button[aria-current="true"]'); - expect(activeSegments).toHaveLength(1); - }); - - it('shows all chapter segments when navigating to a title reference (verse 0)', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); - const titleRef: SerializedVerseRef = { book: 'GEN', chapterNum: 1, verseNum: 0 }; - render(); - - expect(screen.getByText('In')).toBeInTheDocument(); - expect(screen.getByText('And')).toBeInTheDocument(); - }); - - it('shows an error heading and message when book data is a PlatformError', () => { - mockBookData({ platformErrorVersion: 1, message: 'Project not found' }); - render(); - - expect(screen.getByRole('heading', { name: /error loading book/i })).toBeInTheDocument(); - expect(screen.getByText(/project not found/i)).toBeInTheDocument(); - }); - - it('falls back to "und" writing system when useProjectSetting returns a PlatformError', () => { - mockBookData({}); - mockWritingSystem({ platformErrorVersion: 1, message: 'Setting unavailable' }); - render(); - - expect(screen.getByText('In')).toBeInTheDocument(); - expect(extractBookFromUsj).toHaveBeenCalledWith(expect.anything(), 'und'); - }); - - it('falls back to "und" writing system when useProjectSetting returns an empty string', () => { - mockBookData({}); - mockWritingSystem(''); - render(); - - expect(screen.getByText('In')).toBeInTheDocument(); - expect(extractBookFromUsj).toHaveBeenCalledWith(expect.anything(), 'und'); - }); - - it('shows an error heading and message when tokenization throws an Error', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockImplementation(() => { - throw new Error('parse failure'); - }); - render(); - - expect(screen.getByRole('heading', { name: /error processing book/i })).toBeInTheDocument(); - expect(screen.getByText('parse failure')).toBeInTheDocument(); - }); - - it('shows an error message when tokenization throws a non-Error value', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockImplementation(() => { - // eslint-disable-next-line no-throw-literal - throw 'unexpected string error'; - }); - render(); - - expect(screen.getByRole('heading', { name: /error processing book/i })).toBeInTheDocument(); - expect(screen.getByText('unexpected string error')).toBeInTheDocument(); - }); - - it('renders non-word tokens as muted chips', () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_1_PUNCTUATION_BOOK); - render(); - - expect(screen.getByText('.')).toBeInTheDocument(); - }); - - it('calls setScrRef and addRecentScriptureRef when the verse picker submits', async () => { - mockBookData({}); - const mockSetScrRef = jest.fn(); - const mockAddRecentRef = jest.fn(); - jest.mocked(useRecentScriptureRefs).mockReturnValue({ - recentScriptureRefs: [], - addRecentScriptureRef: mockAddRecentRef, - }); - render(); - - await userEvent.click(screen.getByRole('button', { name: /submit reference/i })); - - expect(mockSetScrRef).toHaveBeenCalledWith(defaultScrRef); - expect(mockAddRecentRef).toHaveBeenCalledWith(defaultScrRef); - }); - - it('calls setScrRef with the segment ref when a verse box is clicked', async () => { - mockBookData({}); - jest.mocked(tokenizeBook).mockReturnValue(GEN_1_MULTI_BOOK); - const mockSetScrRef = jest.fn(); - // Start at verse 1; click verse 2's token to select it - render(); - - await userEvent.click(screen.getByText('And')); - - expect(mockSetScrRef).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 }); - }); - - it('passes a book-stable ref to BookUSJ so chapter and verse changes do not re-fetch the book', () => { - const mockBookUSJ = jest.fn().mockReturnValue([{}, jest.fn(), false]); - jest.mocked(useProjectData).mockImplementation(() => ({ BookUSJ: mockBookUSJ })); - const { rerender } = render(); - rerender( - , - ); + it('renders InterlinearizerLoader when a projectId is provided', () => { + render(); - const refsPassed = mockBookUSJ.mock.calls.map((c) => c[0]); - refsPassed.forEach((ref) => expect(ref).toEqual({ book: 'GEN', chapterNum: 1, verseNum: 1 })); - expect(mockBookUSJ.mock.calls.length).toBeGreaterThanOrEqual(2); - refsPassed.slice(1).forEach((ref) => expect(ref).toBe(refsPassed[0])); + expect(screen.getByTestId('interlinearizer-loader')).toBeInTheDocument(); }); }); diff --git a/src/components/ContinuousScrollToggle.tsx b/src/components/ContinuousScrollToggle.tsx new file mode 100644 index 0000000..607801e --- /dev/null +++ b/src/components/ContinuousScrollToggle.tsx @@ -0,0 +1,33 @@ +import { useLocalizedStrings } from '@papi/frontend/react'; +import { Label, Switch } from 'platform-bible-react'; +import { useId, useMemo } from 'react'; + +const STRING_KEYS = ['%interlinearizer_continuousScrollToggle%'] as const; + +/** + * Checkbox toggle UI for the `interlinearizer.continuousScroll` setting. + * + * @param props - Component props + * @param props.checked - Current UI value for continuous scroll + * @param props.onCheckedChange - Callback invoked when user toggles the switch + * @returns A labeled checkbox for continuous-scroll mode + */ +export default function ContinuousScrollToggle({ + checked, + onCheckedChange, +}: Readonly<{ + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}>) { + const [localizedStrings] = useLocalizedStrings(useMemo(() => [...STRING_KEYS], [])); + const switchId = useId(); + + return ( +
+ + +
+ ); +} diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx new file mode 100644 index 0000000..354fd2c --- /dev/null +++ b/src/components/ContinuousView.tsx @@ -0,0 +1,383 @@ +import type { Book, ScriptureRef, Token } from 'interlinearizer'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import MemoizedPhraseBox from './PhraseBox'; +import MemoizedTokenChip from './TokenChip'; + +const STRIP_FADE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)'; +const STRIP_FADE_MS = 500; + +/** + * 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 + * animation. No segment markers, verse labels, or chapter boundaries are shown — the strip is fully + * continuous. + * + * Edge behaviour: + * + * - Previous arrow is disabled (and previous fade suppressed) when the first token is focused. + * - Next arrow is disabled (and next fade suppressed) when the last token is focused. + * + * When `activeVerse` changes the strip jumps to the first token of the matching segment. When arrow + * 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.book - The full tokenized book whose tokens should be streamed + * @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({ + activeVerse, + book, + onVerseChange, +}: Readonly<{ + activeVerse?: ScriptureRef; + book: Book; + onVerseChange?: (verse: ScriptureRef) => void; +}>) { + const isRtl = document.documentElement.dir === 'rtl'; + + const allTokens: Token[] = useMemo( + () => book.segments.flatMap((seg) => seg.tokens), + [book.segments], + ); + + /** Maps each segment id to the index of its first word token in `allTokens`. */ + const segmentStartIndex = useMemo(() => { + const { map } = book.segments.reduce( + (acc, seg) => { + const firstWordIndex = seg.tokens.findIndex((t) => t.type === 'word'); + if (firstWordIndex >= 0) acc.map.set(seg.id, acc.offset + firstWordIndex); + return { map: acc.map, offset: acc.offset + seg.tokens.length }; + }, + { map: new Map(), offset: 0 }, + ); + return map; + }, [book.segments]); + + /** The navigable phrase entries (currently one per word token). */ + const phraseEntries = useMemo( + () => + allTokens + .map((token, tokenIndex) => ({ token, tokenIndex })) + .filter((entry) => entry.token.type === 'word'), + [allTokens], + ); + const phraseEntriesRef = useRef(phraseEntries); + phraseEntriesRef.current = phraseEntries; + + const focusPhraseIndexRef = useRef(0); + + /** Flat token index -> phrase index lookup for focused rendering. */ + const phraseIndexByTokenIndex = useMemo( + () => + phraseEntries.reduce((acc, entry, phraseIndex) => { + acc.set(entry.tokenIndex, phraseIndex); + return acc; + }, new Map()), + [phraseEntries], + ); + + /** Flat token index -> owning segment lookup. */ + const tokenSegment = useMemo( + () => book.segments.flatMap((seg) => seg.tokens.map(() => seg)), + [book.segments], + ); + + /** + * Maps a flat token index to the segment that owns it. Stored in a ref so that a new book object + * reference (same content) does not cause the verse-change effect to re-fire. + */ + const tokenSegmentRef = useRef(tokenSegment); + tokenSegmentRef.current = tokenSegment; + + const getPhraseIndexForVerse = useCallback( + (verse?: ScriptureRef): number | undefined => { + /* v8 ignore next -- verse is always defined at the one call site */ + if (!verse) return; + + const seg = book.segments.find( + (s) => + s.startRef.book === verse.book && + s.startRef.chapter === verse.chapter && + s.startRef.verse === verse.verse, + ); + /* v8 ignore next -- only reachable when an external activeVerse references an unrecognized segment */ + if (!seg) return; + + const tokenIndex = segmentStartIndex.get(seg.id); + if (tokenIndex === undefined) return; + + return phraseIndexByTokenIndex.get(tokenIndex); + }, + [book.segments, segmentStartIndex, phraseIndexByTokenIndex], + ); + + // Lazy-initialize to the target verse so on first render the strip is already positioned + // correctly before the initial-load fade-in fires. + const [focusPhraseIndex, setFocusPhraseIndex] = useState(() => { + if (!activeVerse) return 0; + + const seg = book.segments.find( + (s) => + s.startRef.book === activeVerse.book && + s.startRef.chapter === activeVerse.chapter && + s.startRef.verse === activeVerse.verse, + ); + /* v8 ignore next -- V8 does not track branches inside useState lazy initializer */ + if (!seg) return 0; + + const tokenIdx = segmentStartIndex.get(seg.id); + /* v8 ignore next -- V8 does not track branches inside useState lazy initializer */ + if (tokenIdx === undefined) return 0; + + /* v8 ignore next -- phraseIndexByTokenIndex always has an entry for a valid tokenIdx */ + return phraseIndexByTokenIndex.get(tokenIdx) ?? 0; + }); + + /** + * When activeVerse triggers a programmatic jump we record the target index here. The verse-change + * effect checks this before firing `onVerseChange` so the jump is not echoed back. Using the + * target index (rather than a boolean) avoids a race where the flag gets consumed by an unrelated + * tokenSegment reference change before the focusIndex state update arrives. + */ + focusPhraseIndexRef.current = focusPhraseIndex; + + const jumpTargetRef = useRef(); + const [pendingExternalJumpPhraseIndex, setPendingExternalJumpPhraseIndex] = useState< + number | undefined + >(); + const [isVisible, setIsVisible] = useState(false); + + const isExternalJumpInProgressRef = useRef(false); + const isInitialLoadInProgressRef = useRef(true); + + /** + * 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. + * Initialized to `activeVerse` so the initial mount position (set by the lazy `useState` + * initializer) is treated as already handled, preventing a spurious jump on first render. + */ + const lastInternalVerseRef = useRef(activeVerse); + + // Jump to the first token of the matching segment when the active verse changes. + useEffect(() => { + if (!activeVerse) return; + + // Skip if this activeVerse update is an echo-back of a verse change we reported ourselves. + const lastInternal = lastInternalVerseRef.current; + if ( + lastInternal && + lastInternal.book === activeVerse.book && + lastInternal.chapter === activeVerse.chapter && + lastInternal.verse === activeVerse.verse + ) { + lastInternalVerseRef.current = undefined; + return; + } + + const phraseIndex = getPhraseIndexForVerse(activeVerse); + if (phraseIndex === undefined) return; + + jumpTargetRef.current = phraseIndex; + isExternalJumpInProgressRef.current = true; + setIsVisible(false); + setPendingExternalJumpPhraseIndex(phraseIndex); + // focusPhraseIndexRef is a ref so it is always current without being a dependency. + // Listing focusPhraseIndex here would re-run the effect on every arrow press, causing the + // strip to jump back to the old verse before activeVerse has been updated by the parent. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeVerse?.book, activeVerse?.chapter, activeVerse?.verse, getPhraseIndexForVerse]); + + // Let the fade-out complete before triggering the focus jump scroll. + useEffect(() => { + if (pendingExternalJumpPhraseIndex === undefined) return undefined; + + const timeout = setTimeout(() => { + setFocusPhraseIndex(pendingExternalJumpPhraseIndex); + setPendingExternalJumpPhraseIndex(undefined); + }, STRIP_FADE_MS); + + return () => clearTimeout(timeout); + }, [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. + const firstVisibleSegId = + phraseEntries.length > 0 ? tokenSegment[phraseEntries[0].tokenIndex]?.id : undefined; + const initialFocusedPhrase = phraseEntries[focusPhraseIndex]; + const initialSegId = initialFocusedPhrase + ? tokenSegment[initialFocusedPhrase.tokenIndex]?.id + : firstVisibleSegId; + const lastReportedSegIdRef = useRef(initialSegId); + + // Keep the reported-segment baseline in sync when switching to a different book. + useEffect(() => { + lastReportedSegIdRef.current = firstVisibleSegId; + }, [book.id, firstVisibleSegId]); + + useEffect(() => { + // Suppress echo-back when the change was driven by an incoming activeVerse prop. + if (jumpTargetRef.current === focusPhraseIndex) { + jumpTargetRef.current = undefined; + return; + } + + jumpTargetRef.current = undefined; + const focusedPhrase = phraseEntriesRef.current[focusPhraseIndex]; + /* v8 ignore next -- focusPhraseIndex is always within phraseEntries bounds when state changes */ + if (!focusedPhrase) return; + + const seg = tokenSegmentRef.current[focusedPhrase.tokenIndex]; + if (!seg || seg.id === lastReportedSegIdRef.current) return; + + lastReportedSegIdRef.current = seg.id; + const verse = { + book: seg.startRef.book, + chapter: seg.startRef.chapter, + verse: seg.startRef.verse, + }; + lastInternalVerseRef.current = 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. + const phraseRefs = useRef<(HTMLSpanElement | null)[]>([]); + + const atStart = !phraseEntries.length || !focusPhraseIndex; + const atEnd = !phraseEntries.length || focusPhraseIndex >= phraseEntries.length - 1; + const stripOpacityClass = isVisible ? 'tw-opacity-100' : 'tw-opacity-0'; + + const step = useCallback((delta: number) => { + setFocusPhraseIndex((i) => { + const nextIndex = i + delta; + /* v8 ignore next -- disabled buttons prevent underflow */ + if (nextIndex < 0) return 0; + /* v8 ignore next -- disabled buttons prevent overflow */ + if (nextIndex >= phraseEntriesRef.current.length) return phraseEntriesRef.current.length - 1; + return nextIndex; + }); + }, []); + + const stepPrev = useCallback(() => step(-1), [step]); + + const stepNext = useCallback(() => step(1), [step]); + + const handlePhraseSelect = useCallback( + (index?: number) => { + if (index !== undefined && index !== focusPhraseIndex) { + setFocusPhraseIndex(index); + } + }, + [focusPhraseIndex], + ); + + useEffect(() => { + const isExternalJump = isExternalJumpInProgressRef.current; + const isInitialLoad = isInitialLoadInProgressRef.current; + const shouldJumpInstantly = isExternalJump || isInitialLoad; + phraseRefs.current[focusPhraseIndex]?.scrollIntoView({ + behavior: shouldJumpInstantly ? 'auto' : 'smooth', + block: 'nearest', + inline: 'center', + }); + + if (!isExternalJump && !isInitialLoad) return undefined; + + // Clear the flags now — scrollIntoView has already been called above. Clearing here + // (rather than inside the RAF callback) keeps subsequent scroll behavior deterministic + // regardless of whether the RAF fires before the next focusPhraseIndex change. + if (isExternalJump) isExternalJumpInProgressRef.current = false; + if (isInitialLoad) isInitialLoadInProgressRef.current = false; + + // Defer the fade-in until after the browser applies the instant scroll position. + const rafId = requestAnimationFrame(() => setIsVisible(true)); + + 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); + }; + }, [focusPhraseIndex]); + + return ( +
+ {/* Previous navigation arrow */} + + + {/* Scrollable token strip */} +
+ {/* Previous fade overlay — only rendered when the previous arrow is enabled */} + {!atStart && ( + + ); +} diff --git a/src/components/Interlinearizer.tsx b/src/components/Interlinearizer.tsx new file mode 100644 index 0000000..34242aa --- /dev/null +++ b/src/components/Interlinearizer.tsx @@ -0,0 +1,85 @@ +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import type { Book, ScriptureRef, Segment } from 'interlinearizer'; +import { type ReactNode, useCallback } from 'react'; +import ContinuousView from './ContinuousView'; +import MemoizedSegmentView from './SegmentView'; + +/** + * Main component for the Interlinearizer. Renders a sticky toolbar and continuous view at the top, + * followed by segmented views. + * + * @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 + * @param props.toolbar - Toolbar content rendered at the top of the sticky header + */ +export default function Interlinearizer({ + book, + bookSegments, + continuousScroll, + scrRef, + setScrRef, + toolbar, +}: { + book: Book; + bookSegments: Segment[]; + continuousScroll: boolean; + scrRef: SerializedVerseRef; + setScrRef: (newScrRef: SerializedVerseRef) => void; + toolbar: ReactNode; +}) { + const handleVerseChange = useCallback( + (v: ScriptureRef) => { + setScrRef({ book: v.book, chapterNum: v.chapter, verseNum: v.verse }); + }, + [setScrRef], + ); + + return ( +
+
+ {toolbar} + {continuousScroll && ( +
+ +
+ )} +
+ +
+
+ {bookSegments.length === 0 && ( +

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

+ )} + + {bookSegments.length > 0 && ( +
+ {bookSegments.map((seg) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx new file mode 100644 index 0000000..688954b --- /dev/null +++ b/src/components/InterlinearizerLoader.tsx @@ -0,0 +1,110 @@ +import type { UseWebViewScrollGroupScrRefHook } from '@papi/core'; +import { TabToolbar } from 'platform-bible-react'; +import ContinuousScrollToggle from './ContinuousScrollToggle'; +import ScriptureNavControls from './ScriptureNavControls'; +import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData'; +import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting'; +import Interlinearizer from './Interlinearizer'; + +/** + * Root component for loading the Interlinearizer. Loads book data and settings, then renders error + * and loading states or delegates to {@link Interlinearizer} when data is ready. + * + * @param props - Component props + * @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 + */ +export default function InterlinearizerLoader({ + projectId, + useWebViewScrollGroupScrRef, +}: { + projectId: string; + useWebViewScrollGroupScrRef: UseWebViewScrollGroupScrRefHook; +}) { + const [scrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef(); + + const { + isLoading: isSettingLoading, + onChange: handleContinuousScrollChange, + value: continuousScroll, + } = useOptimisticBooleanSetting(projectId, 'interlinearizer.continuousScroll', true); + + const { book, chapterSegments, isLoading, bookError, tokenizeError } = useInterlinearizerBookData( + { + projectId, + scrRef, + }, + ); + + const hasError = !!bookError || !!tokenizeError; + const showLoading = isLoading || isSettingLoading; + + const toolbar = ( + + } + endAreaChildren={ + !isSettingLoading && ( + + ) + } + /* v8 ignore next -- stub required by TabToolbar API, no behaviour to test */ + onSelectProjectMenuItem={() => {}} + /* v8 ignore next -- stub required by TabToolbar API, no behaviour to test */ + onSelectViewInfoMenuItem={() => {}} + /> + ); + + return hasError || showLoading || !book ? ( +
+ {toolbar} +
+
+ {bookError && ( +
+

Error loading book

+
+                {bookError}
+              
+
+ )} + + {tokenizeError && ( +
+

+ Error processing book +

+
+                {tokenizeError.message}
+              
+
+ )} + + {!hasError && showLoading && ( +

Loading…

+ )} +
+
+
+ ) : ( + + ); +} diff --git a/src/components/PhraseBox.tsx b/src/components/PhraseBox.tsx new file mode 100644 index 0000000..657477f --- /dev/null +++ b/src/components/PhraseBox.tsx @@ -0,0 +1,62 @@ +/** @file Shared phrase-box wrapper used around word tokens. */ +import type { Token } from 'interlinearizer'; +import { memo } from 'react'; +import MemoizedTokenChip from './TokenChip'; + +/** + * Wraps one or more tokens in a phrase-level visual container. + * + * @param props - Component props + * @param props.isFocused - Whether this phrase is the current navigation 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[]; +}>) { + 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 tw-border-border/40 tw-bg-muted/20 tw-px-1 tw-py-0.5'; + const innerContent = ( + + {tokens.map((token) => ( + + ))} + + ); + + if (onClick) { + return ( + + ); + } + + return ( + + {innerContent} + + ); +} + +const MemoizedPhraseBox = memo(PhraseBox); +export default MemoizedPhraseBox; diff --git a/src/components/ScriptureNavControls.tsx b/src/components/ScriptureNavControls.tsx new file mode 100644 index 0000000..481ee73 --- /dev/null +++ b/src/components/ScriptureNavControls.tsx @@ -0,0 +1,44 @@ +import { useLocalizedStrings, useRecentScriptureRefs } from '@papi/frontend/react'; +import { useMemo } from 'react'; +import { + BOOK_CHAPTER_CONTROL_STRING_KEYS, + BookChapterControl, + BookChapterControlProps, + ScrollGroupSelector, + ScrollGroupSelectorProps, +} from 'platform-bible-react'; + +const AVAILABLE_SCROLL_GROUPS = [undefined, 0, 1, 2, 3, 4]; + +type ScriptureNavControlsProps = Pick & + Pick; + +export default function ScriptureNavControls({ + scrRef, + handleSubmit, + scrollGroupId, + onChangeScrollGroupId, +}: ScriptureNavControlsProps) { + const [localizedStrings] = useLocalizedStrings( + useMemo(() => [...BOOK_CHAPTER_CONTROL_STRING_KEYS], []), + ); + const { recentScriptureRefs: recentRefs, addRecentScriptureRef: onAddRecentRef } = + useRecentScriptureRefs(); + + return ( +
+ + +
+ ); +} diff --git a/src/components/SegmentView.tsx b/src/components/SegmentView.tsx new file mode 100644 index 0000000..ca7dabb --- /dev/null +++ b/src/components/SegmentView.tsx @@ -0,0 +1,64 @@ +import type { ScriptureRef, Segment } from 'interlinearizer'; +import { memo } from 'react'; +import MemoizedPhraseBox from './PhraseBox'; +import MemoizedTokenChip from './TokenChip'; + +/** The two display modes for {@link SegmentView}. */ +export type SegmentDisplayMode = 'token-chip' | 'baseline-text'; + +/** + * 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.isActive - Whether this segment is the currently selected verse + * @param props.onClick - Callback invoked when the segment button is clicked + * @param props.segment - The segment to render + * @returns A button containing the segment's verse label and content + */ +export function SegmentView({ + displayMode = 'token-chip', + isActive, + onClick, + segment, +}: Readonly<{ + displayMode?: SegmentDisplayMode; + isActive?: boolean; + onClick?: (ref: ScriptureRef) => void; + segment: Segment; +}>) { + const { book, chapter, verse } = segment.startRef; + + return ( + + ); +} + +const MemoizedSegmentView = memo(SegmentView); +export default MemoizedSegmentView; diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx new file mode 100644 index 0000000..ccc929a --- /dev/null +++ b/src/components/TokenChip.tsx @@ -0,0 +1,25 @@ +import type { Token } from 'interlinearizer'; +import { memo } from 'react'; + +/** + * 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. + * + * @param props - Component props + * @param props.token - The token to render + * @returns A styled inline span + */ +export function TokenChip({ token }: Readonly<{ token: Token }>) { + return token.type === 'word' ? ( + + {token.surfaceText} + + ) : ( + + {token.surfaceText} + + ); +} + +const MemoizedTokenChip = memo(TokenChip); +export default MemoizedTokenChip; diff --git a/src/hooks/useInterlinearizerBookData.ts b/src/hooks/useInterlinearizerBookData.ts new file mode 100644 index 0000000..09b2858 --- /dev/null +++ b/src/hooks/useInterlinearizerBookData.ts @@ -0,0 +1,80 @@ +import { logger } from '@papi/frontend'; +import { useProjectData, useProjectSetting } from '@papi/frontend/react'; +import { SerializedVerseRef } from '@sillsdev/scripture'; +import type { Book } from 'interlinearizer'; +import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor'; +import { tokenizeBook } from 'parsers/papi/bookTokenizer'; +import { isPlatformError } from 'platform-bible-utils'; +import { useEffect, useMemo } from 'react'; + +interface UseInterlinearizerBookDataArgs { + projectId: string; + scrRef: SerializedVerseRef; +} + +interface UseInterlinearizerBookDataResult { + book: Book | undefined; + chapterSegments: Book['segments']; + isLoading: boolean; + bookError: string | undefined; + tokenizeError: { message: string; raw: unknown } | undefined; +} + +export default function useInterlinearizerBookData({ + projectId, + scrRef, +}: Readonly): UseInterlinearizerBookDataResult { + const bookScrRef = useMemo( + () => ({ book: scrRef.book, chapterNum: 1, verseNum: 1 }), + [scrRef.book], + ); + + const [bookResult, , isLoading] = useProjectData('platformScripture.USJ_Book', projectId).BookUSJ( + bookScrRef, + undefined, + ); + const [writingSystem] = useProjectSetting(projectId, 'platform.languageTag', ''); + + const [book, tokenizeError] = useMemo((): [ + Book | undefined, + { message: string; raw: unknown } | undefined, + ] => { + if (!bookResult || isPlatformError(bookResult)) return [undefined, undefined]; + + try { + const ws = isPlatformError(writingSystem) ? 'und' : writingSystem || 'und'; + return [tokenizeBook(extractBookFromUsj(bookResult, ws)), undefined]; + } catch (err) { + return [undefined, { message: err instanceof Error ? err.message : String(err), raw: err }]; + } + }, [bookResult, writingSystem]); + + useEffect(() => { + if (!tokenizeError) return; + + const ws = isPlatformError(writingSystem) ? 'und' : writingSystem || 'und'; + logger.error('Failed to parse/tokenize USJ book', tokenizeError.raw, { + message: tokenizeError.message, + writingSystem: ws, + projectId, + book: scrRef.book, + }); + }, [tokenizeError, writingSystem, projectId, scrRef.book]); + + const chapterSegments = useMemo( + () => + book?.segments.filter( + (seg) => seg.startRef.book === scrRef.book && seg.startRef.chapter === scrRef.chapterNum, + ) ?? [], + [book, scrRef.book, scrRef.chapterNum], + ); + + let bookError: string | undefined; + if (isPlatformError(bookResult)) { + bookError = bookResult.message; + } else if (!isLoading && !bookResult) { + bookError = `No USJ book available for ${scrRef.book} in project ${projectId}`; + } + + return { book, chapterSegments, isLoading, bookError, tokenizeError }; +} diff --git a/src/hooks/useOptimisticBooleanSetting.ts b/src/hooks/useOptimisticBooleanSetting.ts new file mode 100644 index 0000000..bf9ba32 --- /dev/null +++ b/src/hooks/useOptimisticBooleanSetting.ts @@ -0,0 +1,67 @@ +import { useProjectSetting } from '@papi/frontend/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** A timeout duration longer than the 5-10 seconds it usually takes for a setting to save. */ +const TIMEOUT_MS = 15_000; + +/** + * Manages a boolean project setting with optimistic UI updates. + * + * The local value is updated immediately on change and stays locked until timeout elapses, to allow + * the stored setting to finish updating. + * + * @param defaultValue - Default value used when the setting has not been persisted yet + * @param projectId - PAPI project ID + * @param settingKey - A valid key for a boolean setting + * @returns `isLoading` — whether the setting value is still loading from the platform; `onChange` — + * stable change handler; `value` — the current display value + */ +export default function useOptimisticBooleanSetting( + projectId: string, + settingKey: 'interlinearizer.continuousScroll', + defaultValue: boolean, +): { + isLoading: boolean; + onChange: (newValue: boolean) => void; + value: boolean; +} { + const [setting, setSetting, , isLoading] = useProjectSetting(projectId, settingKey, defaultValue); + + const [value, setValue] = useState(typeof setting === 'boolean' ? setting : defaultValue); + + const timeoutRef = useRef | undefined>(); + const ignoreRef = useRef(false); + + // Drive UI from optimistic local state and clear pending once the setting confirms. + useEffect(() => { + // Ignore platform errors or settings that arrive during the timeout period. + if (ignoreRef.current || typeof setting !== 'boolean') return; + + setValue(setting); + }, [setting]); + + // Clean up on unmount. + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + const onChange = useCallback( + (newValue: boolean) => { + setValue(newValue); + ignoreRef.current = true; + setSetting?.(newValue); + // Reset the timeout on every call so back-to-back onChange calls don't let an earlier + // timeout clear the pending value set by a later call. + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + timeoutRef.current = undefined; + ignoreRef.current = false; + }, TIMEOUT_MS); + }, + [setSetting], + ); + + return { isLoading, onChange, value }; +} diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index 4c4a120..1b650d9 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -1,206 +1,8 @@ import type { WebViewProps } from '@papi/core'; -import { - useLocalizedStrings, - useProjectData, - useProjectSetting, - useRecentScriptureRefs, -} from '@papi/frontend/react'; -import { isPlatformError } from 'platform-bible-utils'; -import { useEffect, useMemo } from 'react'; -import { - BOOK_CHAPTER_CONTROL_STRING_KEYS, - BookChapterControl, - ScrollGroupSelector, - TabToolbar, -} from 'platform-bible-react'; -import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor'; -import { tokenizeBook } from 'parsers/papi/bookTokenizer'; -import type { Book, Segment } from 'interlinearizer'; -import { logger } from '@papi/frontend'; - -const AVAILABLE_SCROLL_GROUPS = [undefined, 0, 1, 2, 3, 4]; - -/** - * Renders the tokens of a single segment as inline chips. - * - * @param props - Component props - * @param props.segment - The segment whose tokens to render - * @param props.isActive - Whether this segment is the currently selected verse - * @param props.onClick - Callback invoked when the segment button is clicked - * @returns A button containing the segment's verse label and token chips - */ -function SegmentView({ - segment, - isActive, - onClick, -}: Readonly<{ - segment: Segment; - isActive?: boolean; - onClick?: () => void; -}>) { - return ( - - ); -} - -/** - * Fetches the USJ book for the given project, tokenizes it, and renders all segments in the current - * chapter. Shows loading / error states while data is in flight or unavailable. - * - * @param props - Component props - * @param props.projectId - PAPI project ID whose USJ book to fetch and tokenize - * @param props.scrRef - Current scripture reference shared via the scroll group - * @param props.setScrRef - Setter to update the scroll-group scripture reference when a segment is - * clicked - * @returns A column of {@link SegmentView} chips, or an appropriate loading / error message - */ -function ProjectBookFetcher({ - projectId, - scrRef, - setScrRef, -}: Readonly<{ - projectId: string; - scrRef: ReturnType[0]; - setScrRef: ReturnType[1]; -}>) { - const bookScrRef = useMemo( - () => ({ book: scrRef.book, chapterNum: 1, verseNum: 1 }), - [scrRef.book], - ); - const [bookResult, , isLoading] = useProjectData('platformScripture.USJ_Book', projectId).BookUSJ( - bookScrRef, - undefined, - ); - - const [writingSystem] = useProjectSetting(projectId, 'platform.languageTag', ''); - - const [book, tokenizeError] = useMemo((): [ - Book | undefined, - { message: string; raw: unknown } | undefined, - ] => { - if (!bookResult || isPlatformError(bookResult)) return [undefined, undefined]; - try { - const ws = isPlatformError(writingSystem) ? 'und' : writingSystem || 'und'; - return [tokenizeBook(extractBookFromUsj(bookResult, ws)), undefined]; - } catch (err) { - return [undefined, { message: err instanceof Error ? err.message : String(err), raw: err }]; - } - }, [bookResult, writingSystem]); - - useEffect(() => { - if (tokenizeError) { - const ws = isPlatformError(writingSystem) ? 'und' : writingSystem || 'und'; - logger.error('Failed to parse/tokenize USJ book', tokenizeError.raw, { - message: tokenizeError.message, - writingSystem: ws, - projectId, - book: scrRef.book, - }); - } - }, [tokenizeError, writingSystem, projectId, scrRef.book]); - - const chapterSegments = useMemo( - () => - book?.segments.filter( - (seg) => seg.startRef.book === scrRef.book && seg.startRef.chapter === scrRef.chapterNum, - ) ?? [], - [book, scrRef.book, scrRef.chapterNum], - ); - - let bookError: string | undefined; - if (isPlatformError(bookResult)) { - bookError = bookResult.message; - } else if (!isLoading && bookResult === undefined) { - bookError = `No USJ book available for ${scrRef.book} in project ${projectId}`; - } - - return ( -
- {bookError && ( -
-

Error loading book

-
-            {bookError}
-          
-
- )} - - {tokenizeError && ( -
-

Error processing book

-
-            {tokenizeError.message}
-          
-
- )} - - {!bookError && !tokenizeError && isLoading && ( -

Loading…

- )} - - {!bookError && !tokenizeError && !isLoading && chapterSegments.length === 0 && ( -

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

- )} - - {!bookError && !tokenizeError && !isLoading && chapterSegments.length > 0 && ( -
- {chapterSegments.map((seg) => ( - - setScrRef({ - book: seg.startRef.book, - chapterNum: seg.startRef.chapter, - verseNum: seg.startRef.verse, - }) - } - /> - ))} -
- )} -
- ); -} +import InterlinearizerLoader from './components/InterlinearizerLoader'; /** - * Root WebView component for the Interlinearizer. Renders a sticky reference picker at the top and - * delegates book fetching to {@link ProjectBookFetcher} in the scrollable content area below. + * Root WebView component for the Interlinearizer. * * @param props - WebView props injected by the PAPI host * @param props.projectId - PAPI project ID passed from the host; undefined when the WebView is @@ -213,47 +15,18 @@ globalThis.webViewComponent = function InterlinearizerWebView({ projectId, useWebViewScrollGroupScrRef, }: WebViewProps) { - const [scrRef, setScrRef, scrollGroupId, setScrollGroupId] = useWebViewScrollGroupScrRef(); - - const [localizedStrings] = useLocalizedStrings( - useMemo(() => [...BOOK_CHAPTER_CONTROL_STRING_KEYS], []), - ); - const { recentScriptureRefs: recentRefs, addRecentScriptureRef: onAddRecentRef } = - useRecentScriptureRefs(); - return (
- - } - endAreaChildren={ - - } - onSelectProjectMenuItem={() => {}} - onSelectViewInfoMenuItem={() => {}} - /> - -
- {projectId ? ( - - ) : ( -

- Open this WebView from a Paratext project to load its source book. -

- )} -
+ {projectId ? ( + + ) : ( +

+ Open this WebView from a Paratext project to load its source book. +

+ )}
); }; diff --git a/src/tailwind.css b/src/tailwind.css index 9ada30f..dccbf8c 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -2,6 +2,17 @@ @tailwind components; @tailwind utilities; +@layer utilities { + .no-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; + } + + .no-scrollbar::-webkit-scrollbar { + display: none; + } +} + /* #region shared with https://github.com/paranext/paranext-core/blob/main/lib/platform-bible-react/src/index.css */ @layer base { diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index 009c166..8a5c39b 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -4,6 +4,14 @@ */ declare module 'papi-shared-types' { + export interface ProjectSettingTypes { + /** + * When true, the Interlinearizer displays a continuous horizontal token scroll strip above the + * chapter segments. When false, only chapter segments are shown in token-chip mode. + */ + 'interlinearizer.continuousScroll': boolean; + } + export interface CommandHandlers { /** * Opens the Interlinearizer for the project associated with the given WebView ID. Called from