Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions __mocks__/platform-bible-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -736,13 +736,19 @@ export function Popover({
* Stub popover trigger. With `asChild` (the only mode the extension uses) the real component merges
* its trigger behavior onto the single child element rather than rendering a wrapper, so this stub
* clones the child with the open-state attributes and the toggle handler Radix would supply.
*
* Props cloned onto the trigger itself pass through to that same child, so an outer `asChild`
* trigger — a {@link Tooltip} wrapping a popover-triggering button — reaches the button rather than
* stopping here.
*/
export function PopoverTrigger({
children,
}: Readonly<{ children?: ReactNode; asChild?: boolean }>): ReactNode {
...forwarded
}: Readonly<{ children?: ReactNode; asChild?: boolean }> & Record<string, unknown>): ReactNode {
const { onOpenChange, open = false } = useContext(PopoverContext);
if (!isValidElement(children)) return <>{children}</>;
return cloneElement(children, {
...forwarded,
'aria-expanded': open,
'aria-haspopup': 'dialog',
onClick: () => onOpenChange?.(!open),
Expand Down Expand Up @@ -951,6 +957,9 @@ function tooltipContentText(node: ReactNode): string {
return '';
}

/** Marks that a {@link TooltipProvider} is in scope, so {@link Tooltip} can require one. */
const TooltipProviderContext = createContext(false);

/**
* Stub tooltip root. The real component shows {@link TooltipContent} in a portaled popover on hover;
* because native and Radix tooltips are both invisible in jsdom, this stub instead reads the
Expand All @@ -960,8 +969,22 @@ function tooltipContentText(node: ReactNode): string {
* tooltip in production.
*
* A tooltip whose content contributes no text gets no `title` at all, rather than an empty one.
*
* Props cloned onto the tooltip itself pass through to that same trigger child, so an outer
* `asChild` trigger — a {@link PopoverTrigger} wrapping a tooltipped button — reaches the button
* rather than stopping here, matching how the real components compose.
*
* @throws If rendered outside a {@link TooltipProvider}, as the real component does. A stub that
* rendered anywhere would let a tooltip placed outside every provider pass its tests and throw
* only once the extension ran.
*/
export function Tooltip({ children }: Readonly<{ children?: ReactNode }>): ReactNode {
export function Tooltip({
children,
...forwarded
}: Readonly<{ children?: ReactNode }> & Record<string, unknown>): ReactNode {
if (!useContext(TooltipProviderContext)) {
throw new Error('`Tooltip` must be used within `TooltipProvider`');
}
let tooltipText: ReactNode;
let triggerChild: ReactNode;
Children.forEach(children, (child) => {
Expand All @@ -971,15 +994,19 @@ export function Tooltip({ children }: Readonly<{ children?: ReactNode }>): React
});
if (!isValidElement(triggerChild)) return <>{children}</>;
const text = tooltipContentText(tooltipText);
return cloneElement(triggerChild, { title: text === '' ? undefined : text });
return cloneElement(triggerChild, {
...forwarded,
title: text === '' ? undefined : text,
});
}

/**
* Stub tooltip provider that shares hover-delay config across nested tooltips. The stub renders its
* children unchanged; the delay has no effect in tests.
* children unchanged; the delay has no effect in tests. Its one modeled behavior is satisfying the
* provider requirement {@link Tooltip} enforces.
*/
export function TooltipProvider({
children,
}: Readonly<{ children?: ReactNode; delayDuration?: number }>): ReactElement {
return <>{children}</>;
return <TooltipProviderContext.Provider value>{children}</TooltipProviderContext.Provider>;
}
3 changes: 2 additions & 1 deletion contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"%interlinearizer_tokenChip_defineMorphemes%": "Define morpheme breakdown for {token}",
"%interlinearizer_tokenChip_glossLabel%": "Gloss for {token}",
"%interlinearizer_tokenChip_showSuggestions%": "Show suggestions for {token}",
"%interlinearizer_tokenChip_removeFromPhrase%": "Remove {token} from phrase",
"%interlinearizer_tokenChip_removeFromPhrase%": "Remove \"{token}\" from phrase",
"%interlinearizer_tokenChip_addToPhrase%": "Add \"{token}\" to phrase",
"%interlinearizer_suggestion_accept%": "Accept suggestion {gloss} for {token}",
"%interlinearizer_suggestion_promote%": "Promote {gloss} for {token}",
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Tokens in different segments can't be linked. Join the segments first to link across this boundary.",
Expand Down
87 changes: 61 additions & 26 deletions src/__tests__/components/ArcOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
import { ArcOverlay } from '../../components/ArcOverlay';
import type { ArcPath } from '../../utils/phrase-arc';
import { makePhraseLink } from '../test-helpers';
import { withTooltipProvider } from './test-helpers';

/** Builds a minimal `ArcPath` fixture. */
function makeArcPath(phraseId: string, splitAfterTokenRef = 'tok-a'): ArcPath {
Expand Down Expand Up @@ -39,19 +41,24 @@ function requiredProps(): Parameters<typeof ArcOverlay>[0] {
};
}

/** Renders an `ArcOverlay` inside the `TooltipProvider` its split buttons require. */
function renderOverlay(ui: ReactElement) {
return render(withTooltipProvider(ui));
}

describe('ArcOverlay', () => {
it('returns undefined when arcPaths is empty', () => {
const { container } = render(<ArcOverlay {...requiredProps()} />);
const { container } = renderOverlay(<ArcOverlay {...requiredProps()} />);
expect(container.firstChild).toBeNull();
});

it('renders an SVG when arcPaths has entries', () => {
render(<ArcOverlay {...requiredProps()} arcPaths={[makeArcPath('p1')]} />);
renderOverlay(<ArcOverlay {...requiredProps()} arcPaths={[makeArcPath('p1')]} />);
expect(document.querySelector('svg')).toBeInTheDocument();
});

it('renders one SVG path element per arc', () => {
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a'), makeArcPath('p2', 'tok-b')]}
Expand All @@ -62,7 +69,7 @@ describe('ArcOverlay', () => {

it('does not render split buttons in edit mode', () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b', 'tok-c']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -76,7 +83,7 @@ describe('ArcOverlay', () => {

it('renders a split button in view mode even when the arc phrase is neither hovered nor focused', () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -91,7 +98,7 @@ describe('ArcOverlay', () => {

it('labels the split button with the strip-supplied label', () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -102,9 +109,37 @@ describe('ArcOverlay', () => {
expect(screen.getByRole('button', { name: 'label-from-strip' })).toBeInTheDocument();
});

it('names the split action on hover over the arc button', () => {
// The tooltip text rides the Tooltip component; the mock projects it onto the trigger as `title`.
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
phraseLinkById={new Map([['p1', phraseLink]])}
splitHereLabel="Split phrase here"
/>,
);
expect(screen.getByTestId('split-arc-btn')).toHaveAttribute('title', 'Split phrase here');
});

it('shows no split tooltip while its localized string is still an unresolved key', () => {
// A `%…%` key straight from PAPI's async localization window would be visible hover text; the
// default fixture label is still an unresolved key.
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
phraseLinkById={new Map([['p1', phraseLink]])}
/>,
);
expect(screen.getByTestId('split-arc-btn')).not.toHaveAttribute('title');
});

it('with simplifyPhrases on, hides the split button for a non-focused phrase but keeps its arc', () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -120,7 +155,7 @@ describe('ArcOverlay', () => {

it('with simplifyPhrases on, keeps the split button for the focused phrase', () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -134,7 +169,7 @@ describe('ArcOverlay', () => {

it('renders a split button in view mode when the arc phrase is hovered', () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -153,7 +188,7 @@ describe('ArcOverlay', () => {

it('renders a split button in view mode when the arc phrase is focused', () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -175,7 +210,7 @@ describe('ArcOverlay', () => {
const onArcSplit = jest.fn();
const onSplitHoverChange = jest.fn();
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b', 'tok-c']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand Down Expand Up @@ -203,7 +238,7 @@ describe('ArcOverlay', () => {
const onSplitHoverChange = jest.fn();
// Two-token phrase: splitting after tok-a frees both halves (each length 1).
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -227,7 +262,7 @@ describe('ArcOverlay', () => {
it('calls onSplitHoverChange with undefined on mouse leave', async () => {
const onSplitHoverChange = jest.fn();
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -253,7 +288,7 @@ describe('ArcOverlay', () => {
const onSplitHoverChange = jest.fn();
// Four-token phrase: splitting after tok-b gives before=[tok-a,tok-b] and after=[tok-c,tok-d], both ≥ 2.
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b', 'tok-c', 'tok-d']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-b')]}
Expand Down Expand Up @@ -281,7 +316,7 @@ describe('ArcOverlay', () => {
const onSplitHoverChange = jest.fn();
// Arc refers to 'tok-stale' which is not in the phrase token list.
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-stale')]}
Expand All @@ -304,7 +339,7 @@ describe('ArcOverlay', () => {

it('renders the arc path when a phrase is highlighted only via candidatePhraseIds', () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -325,7 +360,7 @@ describe('ArcOverlay', () => {

it('renders a split button when a phrase is highlighted only via candidatePhraseIds', () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -349,7 +384,7 @@ describe('ArcOverlay', () => {
// splitHoveredArc.phraseId === 'p1' but splitHoveredArc.splitAfterTokenRef !== 'tok-b', so
// tok-b's arc falls through to the focusedPhraseId branch.
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b', 'tok-c']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a'), makeArcPath('p1', 'tok-b')]}
Expand Down Expand Up @@ -390,7 +425,7 @@ describe('ArcOverlay', () => {
// candidatePhraseIds includes 'p1'. When tok-a's split button is hovered, tok-b's arc falls
// through to the candidate highlight.
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b', 'tok-c']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a'), makeArcPath('p1', 'tok-b')]}
Expand Down Expand Up @@ -429,7 +464,7 @@ describe('ArcOverlay', () => {
const onSplitHoverChange = jest.fn();
// Four-token phrase: splitting after tok-b leaves both halves ≥ 2, so no token is freed.
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b', 'tok-c', 'tok-d']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-b')]}
Expand Down Expand Up @@ -458,7 +493,7 @@ describe('ArcOverlay', () => {
it('clears the phrase highlight via onHoverPhrase on leave for a non-freeing split', async () => {
const onHoverPhrase = jest.fn();
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b', 'tok-c', 'tok-d']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-b')]}
Expand All @@ -485,7 +520,7 @@ describe('ArcOverlay', () => {
const onHoverPhrase = jest.fn();
const onArcSplit = jest.fn();
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b', 'tok-c', 'tok-d']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-b')]}
Expand All @@ -512,7 +547,7 @@ describe('ArcOverlay', () => {
it('does not call onHoverPhrase on enter when the split would free a token', async () => {
const onHoverPhrase = jest.fn();
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -534,7 +569,7 @@ describe('ArcOverlay', () => {

it('renders destructive stroke style when the split button is hovered', async () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-a')]}
Expand All @@ -557,7 +592,7 @@ describe('ArcOverlay', () => {
// Four-token phrase: splitting after tok-b leaves both halves ≥ 2, so it is a reshape, not a
// free. The hovered segment dims rather than turning destructive red.
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b', 'tok-c', 'tok-d']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-b')]}
Expand All @@ -582,7 +617,7 @@ describe('ArcOverlay', () => {

it('restores the standard stroke after a non-freeing split hover leaves', async () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b', 'tok-c', 'tok-d']);
render(
renderOverlay(
<ArcOverlay
{...requiredProps()}
arcPaths={[makeArcPath('p1', 'tok-b')]}
Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/components/ContinuousView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,33 @@ describe('ContinuousView scroll behavior', () => {
});
});

it('reveals the strip after an external jump that lands in the group already displayed', async () => {
// Both tokens belong to one phrase, so the jump leaves focusPhraseIndex untouched and the
// scroll effect never re-runs.
const phraseLink = makePhraseLink('phrase-1', ['tok-2', 'tok-3'], ['beginning', 'God']);
phraseLinkMap.set('tok-2', phraseLink);
phraseLinkMap.set('tok-3', phraseLink);
const strip = renderStrip(makeBook(), { focus: 'tok-2' });
const stripClass = () => screen.getByTestId('strip-fade-wrapper').className;
await waitFor(() => expect(stripClass()).toContain('tw:opacity-100'));

jest.useFakeTimers();
try {
strip.setFocus('tok-3', 'list');
expect(stripClass()).toContain('tw:opacity-0');

// Let the fade reach its timeout instead of superseding it, so the reveal has to come from
// the fade completing — the neighboring tests cover the superseded route.
act(() => {
jest.advanceTimersByTime(RECENTER_FADE_MS);
});

expect(stripClass()).toContain('tw:opacity-100');
} finally {
jest.useRealTimers();
}
});

it('reveals the strip again when a phrase click supersedes a jump mid-fade', async () => {
// Opacity does not stop pointer events, so a half-faded phrase box is still clickable.
const strip = renderStrip(makeBook(), { focus: 'tok-0' });
Expand Down
Loading