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