diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index c15fdf8a..84b01dad 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -21,7 +21,7 @@ "%interlinearizer_projectSettings_hideInactiveLinkButtonsDescription%": "Hide link buttons between phrases in segments that are not currently active", "%interlinearizer_projectSettings_simplifyPhrases%": "Show Phrase Controls on Focus Only", "%interlinearizer_projectSettings_simplifyPhrasesDescription%": "Hide interactive controls (split, unlink, remove-token) on phrases that are not currently focused, leaving only their style change on hover", - "%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Cross-segment phrases are not yet supported. This link button is outside the current segment.", + "%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Cross-segment phrases are not supported. This link button is outside the current segment.", "%interlinearizer_modal_create_title%": "Create Interlinear Project", "%interlinearizer_modal_create_name_label%": "Name (optional)", diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index 54554e71..0c33e80d 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -7,9 +7,10 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event'; import type { Book, PhraseAnalysisLink, Token } from 'interlinearizer'; import { useState, type ReactNode } from 'react'; +import type { PhraseDispatch } from '../../components/AnalysisStore'; import ContinuousView from '../../components/ContinuousView'; -import { AnalysisStoreProvider, type PhraseDispatch } from '../../components/AnalysisStore'; -import { isWordToken } from '../../types/typeGuards'; +import { isWordToken } from '../../types/type-guards'; +import { withAnalysisStore } from './test-helpers'; // --------------------------------------------------------------------------- // AnalysisStore mock — pass-through provider so AnalysisStore.tsx stays out of scope @@ -44,13 +45,6 @@ jest.mock('../../components/AnalysisStore', () => ({ usePhraseGlossDispatch: () => () => {}, })); -/** Render options that wrap every test render in a `AnalysisStoreProvider`. */ -const withAnalysisStore = { - wrapper({ children }: Readonly<{ children: ReactNode }>) { - return {children}; - }, -}; - // The shared hover-preview state is covered in full by usePhraseHoverState.test.ts. Stub it here so // ContinuousView's tests don't redundantly re-exercise the hook's internals; the view only forwards // its handlers, which a no-op stub satisfies. @@ -831,15 +825,15 @@ describe('ContinuousView scroll behavior', () => { ); } render(, withAnalysisStore); - // Returns true when the tok-0/tok-1 link icon is rendered AND its sliding-door wrapper is - // open (not suppressed). After the animation change, icons stay mounted but collapse via - // maxWidth: '0' when suppressed — so we query the DOM wrapper's style rather than spy calls. + // Returns true when the tok-0/tok-1 link icon is rendered AND its wrapper is visible (not + // suppressed). Icons stay mounted but are hidden via opacity:0 when suppressed, so + // we query the DOM wrapper's style rather than spy calls. return () => { const icon = document.querySelector( '[data-prev-ref="tok-0"][data-next-ref="tok-1"]', ); if (!icon) return false; - return icon.parentElement?.style.maxWidth !== '0'; + return icon.parentElement?.style.opacity !== '0'; }; } @@ -983,64 +977,25 @@ describe('ContinuousView scroll behavior', () => { ); }); - it('re-centers the focused group each frame while a view-option toggle re-lays out the strip', () => { - // Toggling `hideInactiveLinkButtons` collapses/expands the out-of-segment link slots over - // LINK_SLOT_TRANSITION_MS, continuously shifting every box around the center. A single re-center - // would only fix the first frame; the focused group must be re-centered on every animation frame - // for the whole transition so it stays dead center, then the loop tears down once the transition - // completes. Fake timers drive both the rAF callbacks and performance.now() deterministically. - jest.useFakeTimers(); - try { - const book = makeBook(); - const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); - const { rerender } = render(, withAnalysisStore); - act(() => { - jest.runOnlyPendingTimers(); - }); - scrollIntoViewMock.mockClear(); - - // Toggling hideInactiveLinkButtons changes the strip layout, so the view re-centers. - rerender(); - - // First animation frame re-centers. - act(() => { - jest.advanceTimersByTime(50); - }); - expect(scrollIntoViewMock).toHaveBeenCalledWith( - expect.objectContaining({ behavior: 'auto', inline: 'center' }), - ); - - // Subsequent frames within the transition window re-center again. - scrollIntoViewMock.mockClear(); - act(() => { - jest.advanceTimersByTime(50); - }); - expect(scrollIntoViewMock).toHaveBeenCalledWith( - expect.objectContaining({ behavior: 'auto', inline: 'center' }), - ); + it('re-centers once when simplifyPhrases toggles but not when hideInactiveLinkButtons toggles', () => { + // Inactive link slots are now hidden via visibility:hidden (not max-width collapse), so toggling + // hideInactiveLinkButtons no longer shifts the strip layout — no re-center needed. + // simplifyPhrases still affects layout, so it should trigger one re-center. + const book = makeBook(); + const props = requiredProps(book, { focusedTokenRef: 'tok-0' }); + const { rerender } = render(, withAnalysisStore); + scrollIntoViewMock.mockClear(); - // Advance well past the transition window so the loop hits its deadline and stops scheduling. - act(() => { - jest.advanceTimersByTime(500); - }); - scrollIntoViewMock.mockClear(); - act(() => { - jest.advanceTimersByTime(500); - }); - expect(scrollIntoViewMock).not.toHaveBeenCalled(); + // Toggling hideInactiveLinkButtons should not cause any re-centering. + rerender(); + expect(scrollIntoViewMock).not.toHaveBeenCalled(); - // Toggling simplifyPhrases likewise starts a fresh re-center loop. - scrollIntoViewMock.mockClear(); - rerender(); - act(() => { - jest.advanceTimersByTime(50); - }); - expect(scrollIntoViewMock).toHaveBeenCalledWith( - expect.objectContaining({ behavior: 'auto', inline: 'center' }), - ); - } finally { - jest.useRealTimers(); - } + // Toggling simplifyPhrases re-centers exactly once (no rAF loop needed). + rerender(); + expect(scrollIntoViewMock).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'auto', inline: 'center' }), + ); + expect(scrollIntoViewMock).toHaveBeenCalledTimes(1); }); }); diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 5e9c6423..4bb53f6e 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -12,6 +12,7 @@ import type { Dispatch, SetStateAction } from 'react'; import InterlinearizerLoader from '../../components/InterlinearizerLoader'; import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData'; import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting'; +import { emptyAnalysis } from '../../types/empty-factories'; import type { PhraseMode } from '../../types/phrase-mode'; import { defaultScrRef, GEN_1_1_BOOK, makeWebViewState } from '../test-helpers'; @@ -109,15 +110,6 @@ const mockSendCommand = jest.mocked(papi.commands.sendCommand); const testProjectId = 'test-project-id'; -const STUB_TEXT_ANALYSIS: TextAnalysis = { - segmentAnalyses: [], - segmentAnalysisLinks: [], - tokenAnalyses: [], - tokenAnalysisLinks: [], - phraseAnalyses: [], - phraseAnalysisLinks: [], -}; - const STUB_ACTIVE_PROJECT: MockProject = { id: 'proj-1', createdAt: '2026-01-01T00:00:00Z', @@ -862,7 +854,7 @@ describe('InterlinearizerLoader', () => { describe('project analysis loading', () => { it('passes the stored analysis as initialAnalysis when getProject returns valid JSON', async () => { mockSendCommand.mockResolvedValueOnce( - JSON.stringify({ id: 'proj-1', analysis: STUB_TEXT_ANALYSIS }), + JSON.stringify({ id: 'proj-1', analysis: emptyAnalysis() }), ); await act(async () => render( @@ -874,7 +866,7 @@ describe('InterlinearizerLoader', () => { ), ); - expect(capturedInterlinearizerProps?.initialAnalysis).toEqual(STUB_TEXT_ANALYSIS); + expect(capturedInterlinearizerProps?.initialAnalysis).toEqual(emptyAnalysis()); expect(mockSendCommand).toHaveBeenCalledWith('interlinearizer.getProject', 'proj-1'); }); @@ -915,7 +907,7 @@ describe('InterlinearizerLoader', () => { ); unmount(); - resolveGetProject?.(JSON.stringify({ id: 'proj-1', analysis: STUB_TEXT_ANALYSIS })); + resolveGetProject?.(JSON.stringify({ id: 'proj-1', analysis: emptyAnalysis() })); await Promise.resolve(); expect(jest.mocked(logger.error)).not.toHaveBeenCalled(); @@ -934,11 +926,11 @@ describe('InterlinearizerLoader', () => { ), ); - capturedInterlinearizerProps?.onSaveAnalysis?.(STUB_TEXT_ANALYSIS); + capturedInterlinearizerProps?.onSaveAnalysis?.(emptyAnalysis()); expect(mockSendCommand).toHaveBeenCalledWith( 'interlinearizer.saveAnalysis', 'proj-1', - JSON.stringify(STUB_TEXT_ANALYSIS), + JSON.stringify(emptyAnalysis()), ); }); @@ -951,7 +943,7 @@ describe('InterlinearizerLoader', () => { />, ); - capturedInterlinearizerProps?.onSaveAnalysis?.(STUB_TEXT_ANALYSIS); + capturedInterlinearizerProps?.onSaveAnalysis?.(emptyAnalysis()); expect( mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveAnalysis'), diff --git a/src/__tests__/components/PhraseBox.test.tsx b/src/__tests__/components/PhraseBox.test.tsx index 17e6da45..3870b42f 100644 --- a/src/__tests__/components/PhraseBox.test.tsx +++ b/src/__tests__/components/PhraseBox.test.tsx @@ -12,7 +12,7 @@ import { PhraseStripProvider, type PhraseStripContextValue, } from '../../components/PhraseStripContext'; -import { makePhraseStripContext } from '../test-helpers'; +import { makePhraseStripContext, makeWordToken } from '../test-helpers'; /** Stable mock fns for AnalysisStore hooks — reset between tests via resetMocks. */ const mockUseGloss = jest.fn().mockReturnValue(''); @@ -676,20 +676,6 @@ describe('PhraseBox', () => { ['D', 2], ['E', 3], ]); - /** - * Builds a minimal word token whose surface text equals its ref. - * - * @param ref - Token ref (also used as surface text). - * @returns A word token with the given ref. - */ - const mk = (ref: string): Token & { type: 'word' } => ({ - ref, - surfaceText: ref, - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 1, - }); mockUsePhraseLinkForToken.mockReturnValue(phraseLink); const updatePhraseSpy = jest.fn(); const createPhraseSpy = jest.fn(); @@ -704,7 +690,7 @@ describe('PhraseBox', () => { {...requiredProps()} isHighlighted phraseLink={phraseLink} - tokens={[mk('C'), mk('D'), mk('E')]} + tokens={[makeWordToken('C'), makeWordToken('D'), makeWordToken('E')]} />, { tokenDocOrder: docOrder }, ); @@ -737,20 +723,6 @@ describe('PhraseBox', () => { ['A', 0], ['B', 1], ]); - /** - * Builds a minimal word token whose surface text equals its ref. - * - * @param ref - Token ref (also used as surface text). - * @returns A word token with the given ref. - */ - const mk = (ref: string): Token & { type: 'word' } => ({ - ref, - surfaceText: ref, - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 1, - }); mockUsePhraseLinkForToken.mockReturnValue(phraseLink); const onHoverSplitFreeTokens = jest.fn(); renderBox( @@ -758,7 +730,7 @@ describe('PhraseBox', () => { {...requiredProps()} isHighlighted phraseLink={phraseLink} - tokens={[mk('A'), mk('B')]} + tokens={[makeWordToken('A'), makeWordToken('B')]} />, { tokenDocOrder: docOrder, onHoverSplitFreeTokens }, ); @@ -792,20 +764,6 @@ describe('PhraseBox', () => { ['C', 2], ['D', 3], ]); - /** - * Builds a minimal word token whose surface text equals its ref. - * - * @param ref - Token ref (also used as surface text). - * @returns A word token with the given ref. - */ - const mk = (ref: string): Token & { type: 'word' } => ({ - ref, - surfaceText: ref, - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 1, - }); mockUsePhraseLinkForToken.mockReturnValue(phraseLink); const updatePhraseSpy = jest.fn(); const deletePhraseSpy = jest.fn(); @@ -819,7 +777,7 @@ describe('PhraseBox', () => { {...requiredProps()} isHighlighted phraseLink={phraseLink} - tokens={[mk('A'), mk('B'), mk('C'), mk('D')]} + tokens={[makeWordToken('A'), makeWordToken('B'), makeWordToken('C'), makeWordToken('D')]} />, { tokenDocOrder: docOrder }, ); @@ -908,21 +866,6 @@ describe('PhraseBox', () => { { tokenRef: 'token-4', surfaceText: 'bar' }, ], }; - /** - * Builds a minimal word token with an explicit surface text. - * - * @param ref - Token ref. - * @param surfaceText - Surface text for the token. - * @returns A word token with the given ref and surface text. - */ - const mk = (ref: string, surfaceText: string): Token & { type: 'word' } => ({ - ref, - surfaceText, - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 1, - }); mockUsePhraseLinkForToken.mockReturnValue(fourTokenPhrase); const updatePhraseSpy = jest.fn(); mockUsePhraseDispatch.mockReturnValue({ @@ -936,10 +879,10 @@ describe('PhraseBox', () => { isHighlighted phraseLink={fourTokenPhrase} tokens={[ - mk('token-1', 'Hello'), - mk('token-2', 'World'), - mk('token-3', 'foo'), - mk('token-4', 'bar'), + makeWordToken('token-1', 'Hello'), + makeWordToken('token-2', 'World'), + makeWordToken('token-3', 'foo'), + makeWordToken('token-4', 'bar'), ]} />, ); @@ -964,21 +907,6 @@ describe('PhraseBox', () => { { tokenRef: 'token-4', surfaceText: 'bar' }, ], }; - /** - * Builds a minimal word token with an explicit surface text. - * - * @param ref - Token ref. - * @param surfaceText - Surface text for the token. - * @returns A word token with the given ref and surface text. - */ - const mk = (ref: string, surfaceText: string): Token & { type: 'word' } => ({ - ref, - surfaceText, - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 1, - }); mockUsePhraseLinkForToken.mockReturnValue(fourTokenPhrase); renderBox( { isFocused={false} phraseLink={fourTokenPhrase} tokens={[ - mk('token-1', 'Hello'), - mk('token-2', 'World'), - mk('token-3', 'foo'), - mk('token-4', 'bar'), + makeWordToken('token-1', 'Hello'), + makeWordToken('token-2', 'World'), + makeWordToken('token-3', 'foo'), + makeWordToken('token-4', 'bar'), ]} />, { simplifyPhrases: true }, @@ -1010,21 +938,6 @@ describe('PhraseBox', () => { { tokenRef: 'token-4', surfaceText: 'bar' }, ], }; - /** - * Builds a minimal word token with an explicit surface text. - * - * @param ref - Token ref. - * @param surfaceText - Surface text for the token. - * @returns A word token with the given ref and surface text. - */ - const mk = (ref: string, surfaceText: string): Token & { type: 'word' } => ({ - ref, - surfaceText, - writingSystem: 'en', - type: 'word', - charStart: 0, - charEnd: 1, - }); mockUsePhraseLinkForToken.mockReturnValue(fourTokenPhrase); renderBox( { isFocused phraseLink={fourTokenPhrase} tokens={[ - mk('token-1', 'Hello'), - mk('token-2', 'World'), - mk('token-3', 'foo'), - mk('token-4', 'bar'), + makeWordToken('token-1', 'Hello'), + makeWordToken('token-2', 'World'), + makeWordToken('token-3', 'foo'), + makeWordToken('token-4', 'bar'), ]} />, { simplifyPhrases: true }, diff --git a/src/__tests__/components/PhraseStripParts.test.tsx b/src/__tests__/components/PhraseStripParts.test.tsx index bfac600e..9eab0dfb 100644 --- a/src/__tests__/components/PhraseStripParts.test.tsx +++ b/src/__tests__/components/PhraseStripParts.test.tsx @@ -12,8 +12,9 @@ import { type StripItem, } from '../../components/PhraseStripParts'; import { PhraseStripProvider } from '../../components/PhraseStripContext'; -import type { TokenGroup, LinkSlot, FocusContext } from '../../utils/token-layout'; -import { makePhraseLink, makePhraseStripContext } from '../test-helpers'; +import { emptyFocusContext } from '../../types/empty-factories'; +import type { TokenGroup, LinkSlot, FocusContext } from '../../types/token-layout'; +import { makePhraseLink, makePhraseStripContext, makeWordToken } from '../test-helpers'; // --------------------------------------------------------------------------- // Mocks — keep tests in-lane by stubbing out deep dependencies @@ -69,17 +70,6 @@ jest.mock('../../components/PhraseBox', () => ({ // Helpers // --------------------------------------------------------------------------- -/** - * Creates a word token fixture. - * - * @param ref - Token ref. - * @param surfaceText - Surface text. - * @returns A word token. - */ -function mkWord(ref: string, surfaceText = ref): Token & { type: 'word' } { - return { ref, surfaceText, writingSystem: 'en', type: 'word', charStart: 0, charEnd: 1 }; -} - /** * Creates a punctuation token fixture. * @@ -92,13 +82,7 @@ function mkPunct(ref: string, surfaceText = '.'): Token { } /** A minimal no-focus context. */ -const NO_FOCUS: FocusContext = { - focusedToken: undefined, - focusedPhraseLink: undefined, - focusedFreeToken: undefined, - focusedSegmentId: undefined, - focusedPhraseId: undefined, -}; +const NO_FOCUS: FocusContext = emptyFocusContext(); /** Default props shared by PhraseSlot tests. */ function slotProps(slot: LinkSlot): Parameters[0] { @@ -146,7 +130,7 @@ describe('PhraseSlot', () => { it('renders when the slot has two neighbors', () => { const group: TokenGroup = { - tokens: [mkWord('tok-a')], + tokens: [makeWordToken('tok-a')], phraseLink: undefined, firstIndex: 0, punctuationBetween: [], @@ -159,13 +143,13 @@ describe('PhraseSlot', () => { it('sets phraseRevealed when both neighbors are in the same hovered phrase', () => { const link = makePhraseLink('p1', ['tok-a', 'tok-b']); const prevGroup: TokenGroup = { - tokens: [mkWord('tok-a')], + tokens: [makeWordToken('tok-a')], phraseLink: link, firstIndex: 0, punctuationBetween: [], }; const nextGroup: TokenGroup = { - tokens: [mkWord('tok-b')], + tokens: [makeWordToken('tok-b')], phraseLink: link, firstIndex: 1, punctuationBetween: [], @@ -182,20 +166,20 @@ describe('PhraseSlot', () => { it('sets phraseRevealed via focusedPhraseId when both neighbors are in the same focused phrase', () => { const link = makePhraseLink('p1', ['tok-a', 'tok-b']); const prevGroup: TokenGroup = { - tokens: [mkWord('tok-a')], + tokens: [makeWordToken('tok-a')], phraseLink: link, firstIndex: 0, punctuationBetween: [], }; const nextGroup: TokenGroup = { - tokens: [mkWord('tok-b')], + tokens: [makeWordToken('tok-b')], phraseLink: link, firstIndex: 1, punctuationBetween: [], }; const slot: LinkSlot = { prevGroup, nextGroup, punctuation: [] }; const focusedContext: FocusContext = { - focusedToken: mkWord('tok-a'), + focusedToken: makeWordToken('tok-a'), focusedPhraseLink: link, focusedFreeToken: undefined, focusedSegmentId: 'seg-1', @@ -211,7 +195,7 @@ describe('PhraseSlot', () => { it('renders the link icon when hideInactiveLinkButtons is off', () => { const group: TokenGroup = { - tokens: [mkWord('tok-a')], + tokens: [makeWordToken('tok-a')], phraseLink: undefined, firstIndex: 0, punctuationBetween: [], @@ -226,7 +210,7 @@ describe('PhraseSlot', () => { it('hides the link icon when hideInactiveLinkButtons is on and neither neighbor is in the active segment', () => { const group: TokenGroup = { - tokens: [mkWord('tok-a')], + tokens: [makeWordToken('tok-a')], phraseLink: undefined, firstIndex: 0, punctuationBetween: [], @@ -242,15 +226,15 @@ describe('PhraseSlot', () => { , ); - // Icon stays mounted for smooth sliding-door animation; the wrapper collapses it visually. + // Icon stays mounted but invisible (opacity:0 hides it while the min-height preserves layout space). const icon = screen.getByTestId('link-icon'); - expect(icon.parentElement?.style.maxWidth).toBe('0'); + expect(icon.parentElement?.style.visibility).toBeFalsy(); expect(icon.parentElement?.style.opacity).toBe('0'); }); it('keeps the link icon when hideInactiveLinkButtons is on and both neighbors are in the active segment', () => { const group: TokenGroup = { - tokens: [mkWord('tok-a')], + tokens: [makeWordToken('tok-a')], phraseLink: undefined, firstIndex: 0, punctuationBetween: [], @@ -271,7 +255,7 @@ describe('PhraseSlot', () => { it('hides the cross-verse-boundary link icon when only one neighbor is in the active segment', () => { const group: TokenGroup = { - tokens: [mkWord('tok-a')], + tokens: [makeWordToken('tok-a')], phraseLink: undefined, firstIndex: 0, punctuationBetween: [], @@ -288,9 +272,9 @@ describe('PhraseSlot', () => { , ); - // Icon stays mounted for smooth sliding-door animation; the wrapper collapses it visually. + // Icon stays mounted but invisible (opacity:0 hides it while the min-height preserves layout space). const icon = screen.getByTestId('link-icon'); - expect(icon.parentElement?.style.maxWidth).toBe('0'); + expect(icon.parentElement?.style.visibility).toBeFalsy(); expect(icon.parentElement?.style.opacity).toBe('0'); }); }); @@ -301,7 +285,7 @@ describe('PhraseSlot', () => { describe('MemoizedPhraseGroup', () => { const group: TokenGroup = { - tokens: [mkWord('tok-a', 'Hello')], + tokens: [makeWordToken('tok-a', 'Hello')], phraseLink: undefined, firstIndex: 0, punctuationBetween: [], @@ -398,7 +382,7 @@ describe('PhraseStrip', () => { * @returns A group {@link StripItem}. */ function groupItem(link: PhraseAnalysisLink | undefined, refs: string[]): StripItem { - const tokens = refs.map((r) => mkWord(r)); + const tokens = refs.map((r) => makeWordToken(r)); return { kind: 'group', key: refs[0], diff --git a/src/__tests__/components/SegmentView.test.tsx b/src/__tests__/components/SegmentView.test.tsx index 2a5e16b4..4af6ea69 100644 --- a/src/__tests__/components/SegmentView.test.tsx +++ b/src/__tests__/components/SegmentView.test.tsx @@ -3,14 +3,15 @@ /// import { useLocalizedStrings } from '@papi/frontend/react'; -import { act, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { PhraseAnalysisLink, ScriptureRef, Segment, Token } from 'interlinearizer'; import type { ReactNode } from 'react'; -import { AnalysisStoreProvider, type PhraseDispatch } from '../../components/AnalysisStore'; -import { SegmentView } from '../../components/SegmentView'; +import type { PhraseDispatch } from '../../components/AnalysisStore'; import { LINK_SLOT_TRANSITION_MS } from '../../components/PhraseStripParts'; +import { SegmentView } from '../../components/SegmentView'; import { makePhraseLink } from '../test-helpers'; +import { withAnalysisStore } from './test-helpers'; // --------------------------------------------------------------------------- // AnalysisStore mock — pass-through provider so AnalysisStore.tsx stays out of scope @@ -230,82 +231,53 @@ describe('SegmentView', () => { }); it('renders word token chips in token-chip mode (default)', () => { - render( - - - , - ); + render(, withAnalysisStore); expect(screen.getByText('In')).toBeInTheDocument(); expect(screen.getByText('the')).toBeInTheDocument(); }); it('renders non-word (punctuation) tokens in token-chip mode', () => { - render( - - - , - ); + render(, withAnalysisStore); expect(screen.getByText('.')).toBeInTheDocument(); }); it('renders baselineText in baseline-text mode', () => { - render( - - - , - ); + render(, withAnalysisStore); expect(screen.getByText('In the beginning.')).toBeInTheDocument(); }); it('does not render individual tokens in baseline-text mode', () => { - render( - - - , - ); + render(, withAnalysisStore); expect(screen.queryByText('In')).not.toBeInTheDocument(); expect(screen.queryByText('the')).not.toBeInTheDocument(); }); it('shows the verse number label', () => { - render( - - - , - ); + render(, withAnalysisStore); expect(screen.getByText('1')).toBeInTheDocument(); }); it('sets aria-current="true" when isActive is true', () => { - const { container } = render( - - - , - ); + const { container } = render(, withAnalysisStore); expect(container.firstChild).toHaveAttribute('aria-current', 'true'); }); it('does not set aria-current when isActive is omitted', () => { - const { container } = render( - - - , - ); + const { container } = render(, withAnalysisStore); expect(container.firstChild).not.toHaveAttribute('aria-current'); }); it('sets aria-current="true" on the baseline-text button when isActive is true', () => { const { container } = render( - - - , + , + withAnalysisStore, ); expect(container.firstChild).toHaveAttribute('aria-current', 'true'); @@ -314,9 +286,8 @@ describe('SegmentView', () => { it('calls onSelect when clicked in baseline-text mode', async () => { const handleSelect = jest.fn(); render( - - - , + , + withAnalysisStore, ); await userEvent.click(screen.getByTestId('segment-container')); @@ -327,11 +298,7 @@ describe('SegmentView', () => { it('calls onSelect with the verse ref and token id when a word token is clicked', async () => { const handleSelect = jest.fn(); - render( - - - , - ); + render(, withAnalysisStore); await userEvent.click(screen.getByRole('button', { name: 'In' })); @@ -340,11 +307,7 @@ describe('SegmentView', () => { }); it('renders word tokens as interactive buttons when onSelect is provided', () => { - render( - - - , - ); + render(, withAnalysisStore); expect(screen.getByRole('button', { name: 'In' })).toBeInTheDocument(); }); @@ -364,11 +327,7 @@ describe('SegmentView', () => { ]); mockUsePhraseLinkMap.mockReturnValue(phraseLinkMap); - render( - - - , - ); + render(, withAnalysisStore); // Both tokens are grouped into one PhraseBox (the mock renders both as buttons inside one wrapper) expect(document.querySelectorAll('[data-focus-state]')).toHaveLength(1); @@ -425,11 +384,7 @@ describe('SegmentView', () => { ]), ); - render( - - - , - ); + render(, withAnalysisStore); // boxes[0]=tok-a (1st fragment), boxes[1]=tok-b (free), boxes[2]=tok-c (2nd fragment) const boxes = document.querySelectorAll('[data-show-gloss]'); @@ -438,11 +393,7 @@ describe('SegmentView', () => { }); it('sets focusedGroupSeen when focusedTokenRef matches a token in a group', () => { - render( - - - , - ); + render(, withAnalysisStore); // Just verifies no error — the focusedSideIsPrev computation runs with a matching token. expect(screen.getByText('In')).toBeInTheDocument(); }); @@ -463,23 +414,18 @@ describe('SegmentView', () => { ]), ); render( - - - , + , + withAnalysisStore, ); // In edit mode, EMPTY_SPLIT_FREE_REFS is used — no errors expected. expect(screen.getByText('In')).toBeInTheDocument(); }); it('fires mouse-leave on the token row without throwing', async () => { - render( - - - , - ); + render(, withAnalysisStore); const tokenRow = document.querySelector('.tw\\:token-row'); expect(tokenRow).not.toBeNull(); await userEvent.unhover(tokenRow ?? document.body); @@ -501,11 +447,7 @@ describe('SegmentView', () => { ]); mockUsePhraseLinkMap.mockReturnValue(phraseLinkMap); const onHoverPhrase = jest.fn(); - render( - - - , - ); + render(, withAnalysisStore); // The PhraseGroup wrapper span wraps the mocked PhraseBox span (data-focus-state). const focusStateEl = document.querySelector('[data-focus-state]'); const phraseGroupSpan = focusStateEl?.parentElement; @@ -524,22 +466,21 @@ describe('SegmentView', () => { deletePhrase, mergePhrases: jest.fn(), }); - // Two-token phrase split at tok-0 → both halves are 1 token → deletePhrase called + // Two-token phrase split at tok-0 — both halves are 1 token — deletePhrase called mockUsePhraseLinkMap.mockReturnValue( new Map([['tok-0', makePhraseLink('phrase-1', ['tok-0', 'tok-1'], ['In', 'the'])]]), ); render( - - - , + , + withAnalysisStore, ); await userEvent.click(screen.getByTestId('arc-split-btn')); expect(deletePhrase).toHaveBeenCalledWith('phrase-1'); @@ -553,22 +494,14 @@ describe('SegmentView', () => { deletePhrase, mergePhrases: jest.fn(), }); - render( - - - , - ); + render(, withAnalysisStore); await userEvent.click(screen.getByTestId('arc-split-btn')); expect(deletePhrase).not.toHaveBeenCalled(); }); it('focuses the first word token and updates the verse when the background is clicked', async () => { const handleSelect = jest.fn(); - render( - - - , - ); + render(, withAnalysisStore); await userEvent.click(screen.getByTestId('segment-container')); @@ -579,9 +512,8 @@ describe('SegmentView', () => { it('does nothing on background click when the segment has no word token', async () => { const handleSelect = jest.fn(); render( - - - , + , + withAnalysisStore, ); await userEvent.click(screen.getByTestId('segment-container')); @@ -591,11 +523,7 @@ describe('SegmentView', () => { it('ignores background clicks that bubble up from an interactive child', async () => { const handleSelect = jest.fn(); - render( - - - , - ); + render(, withAnalysisStore); // Clicking the token button calls onSelect itself (with the clicked token), but the bubbled // click on the container must not fire handleBackgroundClick a second time. @@ -607,11 +535,7 @@ describe('SegmentView', () => { it('ignores background clicks that bubble up from a phrase-box label, not just an interactive tag', async () => { const handleSelect = jest.fn(); - render( - - - , - ); + render(, withAnalysisStore); // Clicking a token chip's surface text lands on a