23d1ef6f13
* 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>
628 lines
23 KiB
TypeScript
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);
|
|
});
|
|
});
|