Files
readest/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx
T
Huang Xin 23d1ef6f13 fix(rsvp): restore in-flow control bar layout reverted by #4589 (#4671)
* fix(rsvp): restore in-flow control bar layout reverted by #4589

PR #4585 fixed the mobile RSVP control bar overlap by laying the audio
toggle and settings gear in a single in-flow flex row flanking the
centered transport. PR #4589 branched from main about five minutes
before #4585 merged and merged about ten hours later without rebasing,
so its squash carried the stale pre-#4585 file and reverted the entire
fix, including the regression test #4585 had added.

On narrow phones (360px) the audio and settings icons again overlapped
the right end of the transport, hiding the "skip forward 15" control.

Restore the #4585 layout and re-add a structural guard test asserting
the audio toggle and settings share the transport row and live in no
absolutely positioned cluster. Verified on a Xiaomi 13 (360px) via
on-device CDP: no overlap, play button stays centered.

Also stage the project-memory note for this regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rsvp): hide Faster/Slower buttons at 350px or below

On very narrow phones (width 350px or less) the control row has no room
for every control. Collapse the Faster/Slower speed buttons via a
max-[350px]:hidden variant (matching the existing 350px tightening tier)
so the transport, audio toggle and settings never overflow. Speed stays
adjustable from the WPM dropdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 02:58:47 +02:00

628 lines
23 KiB
TypeScript

'use client';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import RSVPOverlay from '@/app/reader/components/rsvp/RSVPOverlay';
import type { RSVPController, RsvpState } from '@/services/rsvp';
beforeAll(() => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = vi.fn();
}
});
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
vi.mock('@/store/themeStore', () => ({
useThemeStore: () => ({
themeCode: { primary: '#000', bg: '#fff', fg: '#111' },
isDarkMode: false,
}),
}));
// Stub the dictionary popup/sheet so the overlay test does not pull in the
// whole dictionary provider/registry stack — we only assert it opens with the
// word. The overlay uses the sheet below `sm` and the popup otherwise.
vi.mock('@/app/reader/components/annotator/DictionarySheet', () => ({
default: ({ word, onDismiss }: { word: string; onDismiss: () => void }) => (
<div data-testid='rsvp-dict-sheet' data-word={word}>
<button aria-label='close-dict' onClick={onDismiss}>
x
</button>
</div>
),
}));
vi.mock('@/app/reader/components/annotator/DictionaryPopup', () => ({
default: ({ word, onDismiss }: { word: string; onDismiss: () => void }) => (
<div data-testid='rsvp-dict-popup' data-word={word}>
<button aria-label='close-dict' onClick={onDismiss}>
x
</button>
</div>
),
}));
const buildState = (overrides: Partial<RsvpState> = {}): RsvpState => ({
active: true,
playing: false,
words: [],
currentIndex: 0,
currentPartIndex: 0,
wpm: 300,
punctuationPauseMs: 100,
splitHyphens: false,
cjkCharMode: false,
startDelaySeconds: 3,
hasCJK: false,
progress: 0,
...overrides,
});
const buildController = (state: RsvpState) => {
const listeners = new Map<string, EventListener[]>();
const controller = {
get currentState() {
return state;
},
get currentDisplayWord() {
return state.words[state.currentIndex] ?? null;
},
get currentCountdown() {
return null;
},
seekToIndex: vi.fn(),
seekToPosition: vi.fn(),
skipBackward: vi.fn(),
skipForward: vi.fn(),
nextWord: vi.fn(),
prevWord: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
togglePlayPause: vi.fn(),
decreaseSpeed: vi.fn(),
increaseSpeed: vi.fn(),
setWpm: vi.fn(),
setPunctuationPause: vi.fn(),
setSplitHyphens: vi.fn(),
setCjkCharMode: vi.fn(),
setStartDelay: vi.fn(),
getWpmOptions: vi.fn(() => [100, 200, 300]),
getPunctuationPauseOptions: vi.fn(() => [25, 50, 100]),
getStartDelayOptions: vi.fn(() => [0, 1, 2, 3]),
addEventListener: vi.fn((type: string, listener: EventListener) => {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type)!.push(listener);
}),
removeEventListener: vi.fn(),
};
return controller;
};
const renderOverlay = (state: RsvpState, fontFamily?: string) => {
const controller = buildController(state);
const result = render(
<RSVPOverlay
gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }}
controller={controller as unknown as RSVPController}
chapters={[]}
currentChapterHref={null}
fontFamily={fontFamily}
onClose={vi.fn()}
onChapterSelect={vi.fn()}
onRequestNextPage={vi.fn()}
/>,
);
return { ...result, controller };
};
describe('RSVPOverlay — context panel performance', () => {
afterEach(() => cleanup());
test('renders a bounded number of word buttons for a large word list', () => {
const words = Array.from({ length: 5000 }, (_, i) => ({
text: `word${i}`,
orpIndex: 0,
pauseMultiplier: 1,
}));
const state = buildState({ words, currentIndex: 2500 });
const { container } = renderOverlay(state);
const buttons = container.querySelectorAll('[data-rsvp-word-button]');
// Without windowing this would be 5000; with windowing it should be far fewer.
expect(buttons.length).toBeLessThan(2000);
expect(buttons.length).toBeGreaterThan(0);
});
test('clicking a windowed word seeks to that word', () => {
const words = Array.from({ length: 200 }, (_, i) => ({
text: `w${i}`,
orpIndex: 0,
pauseMultiplier: 1,
}));
const state = buildState({ words, currentIndex: 100 });
const { container, controller } = renderOverlay(state);
const target = container.querySelector('[data-rsvp-word-index="90"]');
expect(target).not.toBeNull();
fireEvent.click(target!);
expect(controller.seekToIndex).toHaveBeenCalledWith(90);
});
test('the current word is rendered with the highlight ref', () => {
const words = Array.from({ length: 100 }, (_, i) => ({
text: `w${i}`,
orpIndex: 0,
pauseMultiplier: 1,
}));
const state = buildState({ words, currentIndex: 42 });
const { container } = renderOverlay(state);
const current = container.querySelector('[data-rsvp-word-index="42"]');
expect(current).not.toBeNull();
// current word should not be a button (not clickable)
expect(current!.getAttribute('role')).toBeNull();
});
});
describe('RSVPOverlay — reading font', () => {
afterEach(() => cleanup());
const wordState = () =>
buildState({ words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }], currentIndex: 0 });
test('applies the reader font family to the word display', () => {
const { container } = renderOverlay(wordState(), '"Bitter", "Source Han Serif CN", serif');
const word = container.querySelector('.rsvp-word') as HTMLElement;
expect(word).not.toBeNull();
expect(word.style.fontFamily).toContain('Bitter');
// With a reading font supplied, the word no longer uses the monospace fallback.
expect(word.classList.contains('font-mono')).toBe(false);
});
test('falls back to the monospace class when no font family is supplied', () => {
const { container } = renderOverlay(wordState());
const word = container.querySelector('.rsvp-word') as HTMLElement;
expect(word).not.toBeNull();
expect(word.classList.contains('font-mono')).toBe(true);
expect(word.style.fontFamily).toBe('');
});
});
describe('RSVPOverlay — progress bar drag on mobile', () => {
afterEach(() => cleanup());
test('horizontal drag starting on the progress bar does not trigger a speed swipe', () => {
const words = Array.from({ length: 100 }, (_, i) => ({
text: `w${i}`,
orpIndex: 0,
pauseMultiplier: 1,
}));
const state = buildState({ words, currentIndex: 10 });
const { container, controller } = renderOverlay(state);
const slider = container.querySelector('[role="slider"]') as HTMLElement;
expect(slider).not.toBeNull();
// Simulate a horizontal touch drag long enough to clear SWIPE_THRESHOLD (50px).
fireEvent.touchStart(slider, { touches: [{ clientX: 50, clientY: 400 }] });
fireEvent.touchEnd(slider, {
changedTouches: [{ clientX: 200, clientY: 400 }],
});
expect(controller.increaseSpeed).not.toHaveBeenCalled();
expect(controller.decreaseSpeed).not.toHaveBeenCalled();
});
test('horizontal drag starting on a footer button does not trigger a speed swipe', () => {
const state = buildState({
words: [{ text: 'a', orpIndex: 0, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container, controller } = renderOverlay(state);
// Pick any control inside `.rsvp-controls` (e.g. the play/pause button).
const playButton = container.querySelector('[aria-label="Play"]') as HTMLElement;
expect(playButton).not.toBeNull();
fireEvent.touchStart(playButton, { touches: [{ clientX: 50, clientY: 400 }] });
fireEvent.touchEnd(playButton, {
changedTouches: [{ clientX: 200, clientY: 400 }],
});
expect(controller.increaseSpeed).not.toHaveBeenCalled();
expect(controller.decreaseSpeed).not.toHaveBeenCalled();
});
test('progress bar uses touch-action: none so pointer capture survives on mobile', () => {
const state = buildState({
words: [{ text: 'a', orpIndex: 0, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container } = renderOverlay(state);
const slider = container.querySelector('[role="slider"]') as HTMLElement;
expect(slider).not.toBeNull();
expect(slider.style.touchAction).toBe('none');
});
});
describe('RSVPOverlay — CJK reading options', () => {
afterEach(() => {
cleanup();
localStorage.clear();
});
const openSettings = (container: HTMLElement) => {
fireEvent.click(container.querySelector('[aria-label="Settings"]') as HTMLElement);
};
test('shows Character Mode and Highlight Word toggles for CJK sections', () => {
const state = buildState({
words: [{ text: '喜欢', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
hasCJK: true,
});
const { container } = renderOverlay(state);
openSettings(container);
expect(container.querySelector('[data-testid="rsvp-char-mode-toggle"]')).not.toBeNull();
expect(container.querySelector('[data-testid="rsvp-highlight-word-toggle"]')).not.toBeNull();
});
test('hides CJK toggles when the section has no CJK text', () => {
const state = buildState({
words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
hasCJK: false,
});
const { container } = renderOverlay(state);
openSettings(container);
expect(container.querySelector('[data-testid="rsvp-char-mode-toggle"]')).toBeNull();
expect(container.querySelector('[data-testid="rsvp-highlight-word-toggle"]')).toBeNull();
});
test('toggling Character Mode calls controller.setCjkCharMode', () => {
const state = buildState({
words: [{ text: '喜欢', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
hasCJK: true,
});
const { container, controller } = renderOverlay(state);
openSettings(container);
fireEvent.click(
container.querySelector('[data-testid="rsvp-char-mode-toggle"]') as HTMLElement,
);
expect(controller.setCjkCharMode).toHaveBeenCalledWith(true);
});
test('renders the focus-letter layout for a CJK word by default', () => {
const state = buildState({
words: [{ text: '喜欢', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
hasCJK: true,
});
const { container } = renderOverlay(state);
expect(container.querySelector('.rsvp-word-orp')).not.toBeNull();
expect(container.querySelector('.rsvp-word-whole')).toBeNull();
});
test('renders a single centered span when Highlight Word is enabled', () => {
localStorage.setItem('readest_rsvp_cjk_highlight_word', '1');
const state = buildState({
words: [{ text: '喜欢', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
hasCJK: true,
});
const { container } = renderOverlay(state);
const whole = container.querySelector('.rsvp-word-whole');
expect(whole).not.toBeNull();
expect(whole!.textContent).toBe('喜欢');
expect(container.querySelector('.rsvp-word-orp')).toBeNull();
});
test('keeps the focus-letter layout for Latin words even with Highlight Word enabled', () => {
localStorage.setItem('readest_rsvp_cjk_highlight_word', '1');
const state = buildState({
words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
hasCJK: false,
});
const { container } = renderOverlay(state);
expect(container.querySelector('.rsvp-word-orp')).not.toBeNull();
expect(container.querySelector('.rsvp-word-whole')).toBeNull();
});
});
describe('RSVPOverlay — RTL word display (#4630)', () => {
afterEach(() => {
cleanup();
localStorage.clear();
});
test('renders an Arabic word as a single RTL whole-word span, never split', () => {
const state = buildState({
words: [{ text: 'علم', orpIndex: 0, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container } = renderOverlay(state);
// Splitting the word into before/orp/after spans breaks Arabic shaping and
// reverses the visual order — RTL words must render whole instead.
const whole = container.querySelector('.rsvp-word-whole');
expect(whole).not.toBeNull();
expect(whole!.textContent).toBe('علم');
expect(whole!.getAttribute('dir')).toBe('rtl');
expect(container.querySelector('.rsvp-word-orp')).toBeNull();
expect(container.querySelector('.rsvp-word-before')).toBeNull();
expect(container.querySelector('.rsvp-word-after')).toBeNull();
});
test('renders a Hebrew word whole as well', () => {
const state = buildState({
words: [{ text: 'שלום', orpIndex: 0, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container } = renderOverlay(state);
const whole = container.querySelector('.rsvp-word-whole');
expect(whole).not.toBeNull();
expect(whole!.textContent).toBe('שלום');
expect(container.querySelector('.rsvp-word-orp')).toBeNull();
});
test('keeps the focus-letter split for Latin words (no spurious dir)', () => {
const state = buildState({
words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container } = renderOverlay(state);
expect(container.querySelector('.rsvp-word-orp')).not.toBeNull();
expect(container.querySelector('.rsvp-word-whole')).toBeNull();
});
});
describe('RSVPOverlay — manual word stepping (#4476)', () => {
afterEach(() => cleanup());
const wordsState = () =>
buildState({
words: Array.from({ length: 10 }, (_, i) => ({
text: `w${i}`,
orpIndex: 0,
pauseMultiplier: 1,
})),
currentIndex: 5,
playing: true,
});
test('the next-word button calls controller.nextWord', () => {
const { container, controller } = renderOverlay(wordsState());
const button = container.querySelector('[aria-label="Next word"]') as HTMLElement;
expect(button).not.toBeNull();
fireEvent.click(button);
expect(controller.nextWord).toHaveBeenCalledTimes(1);
});
test('the previous-word button calls controller.prevWord', () => {
const { container, controller } = renderOverlay(wordsState());
const button = container.querySelector('[aria-label="Previous word"]') as HTMLElement;
expect(button).not.toBeNull();
fireEvent.click(button);
expect(controller.prevWord).toHaveBeenCalledTimes(1);
});
test('the "." key steps to the next word and "," to the previous word', () => {
const { controller } = renderOverlay(wordsState());
fireEvent.keyDown(document, { key: '.' });
expect(controller.nextWord).toHaveBeenCalledTimes(1);
fireEvent.keyDown(document, { key: ',' });
expect(controller.prevWord).toHaveBeenCalledTimes(1);
});
});
describe('RSVPOverlay — dictionary lookup (#4475)', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const wordsState = () =>
buildState({
words: Array.from({ length: 10 }, (_, i) => ({
text: `w${i}`,
orpIndex: 0,
pauseMultiplier: 1,
})),
currentIndex: 5,
playing: true,
});
const mockSelection = (text: string, node: Node | null) => {
const rect = { left: 20, top: 30, right: 60, bottom: 44, width: 40, height: 14 };
const range = {
getBoundingClientRect: () => rect,
cloneRange() {
return range;
},
};
vi.spyOn(window, 'getSelection').mockReturnValue({
isCollapsed: text.length === 0,
anchorNode: node,
rangeCount: 1,
toString: () => text,
getRangeAt: () => range,
removeAllRanges: vi.fn(),
} as unknown as Selection);
};
test('the context panel is selectable', () => {
const { container } = renderOverlay(wordsState());
const panel = container.querySelector('[data-testid="rsvp-context-panel"]') as HTMLElement;
expect(panel.className).toContain('select-text');
});
test('selecting text in the context panel reveals a Look up action', () => {
const { container } = renderOverlay(wordsState());
const panel = container.querySelector('[data-testid="rsvp-context-panel"]') as HTMLElement;
mockSelection('serendipity', panel);
fireEvent.mouseUp(panel);
expect(container.querySelector('[aria-label="Look up"]')).not.toBeNull();
});
test('tapping Look up pauses playback and opens the dictionary with the selected text', () => {
const { container, controller } = renderOverlay(wordsState());
const panel = container.querySelector('[data-testid="rsvp-context-panel"]') as HTMLElement;
mockSelection('serendipity', panel);
fireEvent.mouseUp(panel);
fireEvent.click(container.querySelector('[aria-label="Look up"]') as HTMLElement);
expect(controller.pause).toHaveBeenCalled();
// jsdom's default viewport is desktop-sized, so the anchored popup is used.
const popup = container.querySelector('[data-testid="rsvp-dict-popup"]');
expect(popup).not.toBeNull();
expect(popup!.getAttribute('data-word')).toBe('serendipity');
});
test('clicking outside the popup dismisses it', () => {
const { container } = renderOverlay(wordsState());
const panel = container.querySelector('[data-testid="rsvp-context-panel"]') as HTMLElement;
mockSelection('serendipity', panel);
fireEvent.mouseUp(panel);
fireEvent.click(container.querySelector('[aria-label="Look up"]') as HTMLElement);
expect(container.querySelector('[data-testid="rsvp-dict-popup"]')).not.toBeNull();
// The transparent full-screen catcher behind the popup dismisses on click.
fireEvent.click(container.querySelector('.overlay') as HTMLElement);
expect(container.querySelector('[data-testid="rsvp-dict-popup"]')).toBeNull();
});
test('uses the bottom sheet on small screens', () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(420);
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(720);
const { container } = renderOverlay(wordsState());
const panel = container.querySelector('[data-testid="rsvp-context-panel"]') as HTMLElement;
mockSelection('serendipity', panel);
fireEvent.mouseUp(panel);
fireEvent.click(container.querySelector('[aria-label="Look up"]') as HTMLElement);
expect(container.querySelector('[data-testid="rsvp-dict-sheet"]')).not.toBeNull();
expect(container.querySelector('[data-testid="rsvp-dict-popup"]')).toBeNull();
});
test('an active selection suppresses word-click seeking', () => {
const { container, controller } = renderOverlay(wordsState());
const panel = container.querySelector('[data-testid="rsvp-context-panel"]') as HTMLElement;
mockSelection('w3 w4', panel);
fireEvent.click(container.querySelector('[data-rsvp-word-index="3"]') as HTMLElement);
expect(controller.seekToIndex).not.toHaveBeenCalled();
});
});
describe('RSVPOverlay — playback control layout (#4585, regressed by #4589)', () => {
afterEach(() => cleanup());
// The audio (TTS) toggle and the settings gear must sit in the SAME flex row
// as the transport buttons, flanking the centered play button in normal flow.
// The earlier `absolute end-0` cluster overlaid them on top of the right end
// of the transport, hiding the audio button behind "skip forward 15" on narrow
// phones. Keeping all three as siblings of the play button is what prevents the
// overlap, so assert the structure here (jsdom can't measure the overlap).
test('audio toggle and settings flank the transport in the same flex row', () => {
const state = buildState({
words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container } = renderOverlay(state);
const audioButton = container.querySelector('[aria-label="Play audio"]') as HTMLElement;
const settingsButton = container.querySelector('[aria-label="Settings"]') as HTMLElement;
const playButton = container.querySelector('[aria-label="Play"]') as HTMLElement;
expect(audioButton).not.toBeNull();
expect(settingsButton).not.toBeNull();
expect(playButton).not.toBeNull();
// All three share the play button's parent (the single flex row) — the audio
// toggle and settings are not tucked into a separate absolute cluster.
expect(audioButton.parentElement).toBe(playButton.parentElement);
expect(settingsButton.parentElement).toBe(playButton.parentElement);
});
// On very narrow phones (< 350px) the row has no room for every control, so
// the Faster/Slower speed buttons collapse to save space (speed is still
// adjustable from the WPM dropdown). The core transport stays put.
test('hides the Faster/Slower buttons below 350px to save space', () => {
const state = buildState({
words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container } = renderOverlay(state);
const decrease = container.querySelector('[aria-label="Decrease speed"]') as HTMLElement;
const increase = container.querySelector('[aria-label="Increase speed"]') as HTMLElement;
const play = container.querySelector('[aria-label="Play"]') as HTMLElement;
const audio = container.querySelector('[aria-label="Play audio"]') as HTMLElement;
const settings = container.querySelector('[aria-label="Settings"]') as HTMLElement;
expect(decrease.className).toContain('max-[350px]:hidden');
expect(increase.className).toContain('max-[350px]:hidden');
// Transport, audio toggle and settings must remain visible at any width.
expect(play.className).not.toContain('max-[350px]:hidden');
expect(audio.className).not.toContain('max-[350px]:hidden');
expect(settings.className).not.toContain('max-[350px]:hidden');
});
// The previous fix relied on absolute positioning being gone; guard against it
// sneaking back into the row that holds the transport controls.
test('the playback control row is not absolutely positioned', () => {
const state = buildState({
words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container } = renderOverlay(state);
const playButton = container.querySelector('[aria-label="Play"]') as HTMLElement;
const audioButton = container.querySelector('[aria-label="Play audio"]') as HTMLElement;
expect(playButton.parentElement!.className).not.toContain('absolute');
expect(audioButton.parentElement!.className).not.toContain('absolute');
});
});
describe('RSVPOverlay — start delay setting (#4478)', () => {
afterEach(() => {
cleanup();
localStorage.clear();
});
const openSettings = (container: HTMLElement) => {
fireEvent.click(container.querySelector('[aria-label="Settings"]') as HTMLElement);
};
test('changing the Start Delay select calls controller.setStartDelay', () => {
const state = buildState({
words: [{ text: 'a', orpIndex: 0, pauseMultiplier: 1 }],
currentIndex: 0,
startDelaySeconds: 3,
});
const { container, controller } = renderOverlay(state);
openSettings(container);
const select = container.querySelector(
'[data-testid="rsvp-start-delay-select"]',
) as HTMLSelectElement;
expect(select).not.toBeNull();
fireEvent.change(select, { target: { value: '0' } });
expect(controller.setStartDelay).toHaveBeenCalledWith(0);
});
});