refactor(tts): enhance pause handling and centralize TTS test helpers

Update TTSContext to track manual pause epochs, ensuring explicit user
pauses are respected during async sentence splitting and playback state
restoration. Adjust TTSPlayer button disabling logic to allow pausing
while processing. Move and improve pauseTTSAndVerify helper for robust
cross-browser TTS pause assertions, replacing local test implementations.
Update expectDocumentListed to use polling for document link visibility.
This commit is contained in:
Richard R 2026-05-04 19:38:23 -06:00
parent 62103ea752
commit 4cb482c3f2
5 changed files with 43 additions and 25 deletions

View file

@ -62,7 +62,7 @@ export default function TTSPlayer({ currentPage, numPages }: {
onClick={togglePlay} onClick={togglePlay}
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent" className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label={isPlaying ? 'Pause' : 'Play'} aria-label={isPlaying ? 'Pause' : 'Play'}
disabled={isProcessing} disabled={isProcessing && !isPlaying}
> >
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />} {isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
</Button> </Button>

View file

@ -433,6 +433,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState<TTSSentenceAlignment | undefined>(); const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState<TTSSentenceAlignment | undefined>();
const [currentWordIndex, setCurrentWordIndex] = useState<number | null>(null); const [currentWordIndex, setCurrentWordIndex] = useState<number | null>(null);
const isPlayingRef = useRef(false); const isPlayingRef = useRef(false);
const pauseEpochRef = useRef(0);
const sentencesRef = useRef<string[]>([]); const sentencesRef = useRef<string[]>([]);
const currentIndexRef = useRef(0); const currentIndexRef = useRef(0);
const setTextRef = useRef<(text: string, options?: boolean | SetTextOptions) => void>(() => { }); const setTextRef = useRef<(text: string, options?: boolean | SetTextOptions) => void>(() => { });
@ -642,14 +643,21 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
}, [activeHowl, clearRateWatchdog]); }, [activeHowl, clearRateWatchdog]);
const recordManualPause = useCallback(() => {
// Cancel any queued auto-resume intent and mark an explicit user pause.
resumeAfterLocationChangeRef.current = false;
pauseEpochRef.current += 1;
}, []);
/** /**
* Pauses the current audio playback * Pauses the current audio playback
* Used for external control of playback state * Used for external control of playback state
*/ */
const pause = useCallback(() => { const pause = useCallback(() => {
recordManualPause();
pauseActiveHowl(); pauseActiveHowl();
setIsPlaying(false); setIsPlaying(false);
}, [pauseActiveHowl]); }, [pauseActiveHowl, recordManualPause]);
/** /**
* Navigates to a specific location in the document * Navigates to a specific location in the document
@ -882,6 +890,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (handleBlankSection(workingText)) return; if (handleBlankSection(workingText)) return;
const shouldPause = normalizedOptions.shouldPause ?? false; const shouldPause = normalizedOptions.shouldPause ?? false;
const pauseEpochAtStart = pauseEpochRef.current;
const pendingAutoResume = resumeAfterLocationChangeRef.current; const pendingAutoResume = resumeAfterLocationChangeRef.current;
const shouldResumePlayback = !shouldPause && (isPlaying || pendingAutoResume); const shouldResumePlayback = !shouldPause && (isPlaying || pendingAutoResume);
if (shouldPause || pendingAutoResume) { if (shouldPause || pendingAutoResume) {
@ -968,7 +977,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setIsProcessing(false); setIsProcessing(false);
// Restore playback state if needed // Restore playback state if needed
if (shouldResumePlayback) { // Respect explicit pauses that happened while sentence splitting was in flight.
if (shouldResumePlayback && pauseEpochRef.current === pauseEpochAtStart) {
setIsPlaying(true); setIsPlaying(true);
} }
}) })
@ -990,6 +1000,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
*/ */
const togglePlay = useCallback(() => { const togglePlay = useCallback(() => {
if (isPlaying) { if (isPlaying) {
recordManualPause();
pauseActiveHowl(); pauseActiveHowl();
setIsPlaying(false); setIsPlaying(false);
return; return;
@ -1014,7 +1025,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
setIsPlaying(true); setIsPlaying(true);
}, [activeHowl, applyPlaybackRateToHowl, isPlaying, pauseActiveHowl, unlockPlaybackOnUserGesture]); }, [activeHowl, applyPlaybackRateToHowl, isPlaying, pauseActiveHowl, recordManualPause, unlockPlaybackOnUserGesture]);
/** /**

View file

@ -5,6 +5,7 @@ import {
ensureDocumentsListed, ensureDocumentsListed,
uploadAndDisplay, uploadAndDisplay,
expectProcessingTransition, expectProcessingTransition,
pauseTTSAndVerify,
} from './helpers'; } from './helpers';
test.describe('Accessibility smoke', () => { test.describe('Accessibility smoke', () => {
@ -81,8 +82,6 @@ test.describe('Accessibility smoke', () => {
await playBtn.click(); await playBtn.click();
await expectProcessingTransition(page); await expectProcessingTransition(page);
await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible();
await pauseTTSAndVerify(page);
await page.getByRole('button', { name: 'Pause' }).click();
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
}); });
}); });

View file

@ -190,11 +190,23 @@ export async function playTTSAndWaitForASecond(page: Page, fileName: string) {
* Pause TTS playback and verify paused state * Pause TTS playback and verify paused state
*/ */
export async function pauseTTSAndVerify(page: Page) { export async function pauseTTSAndVerify(page: Page) {
// Click pause to stop playback const ttsbar = page.locator('[data-app-ttsbar]');
await page.getByRole('button', { name: 'Pause' }).click(); const pauseButton = ttsbar.getByRole('button', { name: 'Pause' }).first();
await expect(pauseButton).toBeVisible({ timeout: 15000 });
await expect(pauseButton).toBeEnabled({ timeout: 15000 });
// Check for play button to be visible // Retry pause because Firefox can race with transient processing transitions.
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 }); for (let attempt = 0; attempt < 3; attempt++) {
await pauseButton.click();
try {
await expect(ttsbar.getByRole('button', { name: 'Play' }).first()).toBeVisible({ timeout: 5000 });
await expectMediaState(page, 'paused');
return;
} catch {
if (attempt === 2) throw new Error('Failed to pause TTS playback after retries');
await page.waitForTimeout(250);
}
}
} }
/** /**
@ -318,9 +330,11 @@ export async function dispatchHtml5DragAndDrop(page: Page, source: Locator, targ
// Assert a document link containing the given filename appears in the list // Assert a document link containing the given filename appears in the list
export async function expectDocumentListed(page: Page, fileName: string) { export async function expectDocumentListed(page: Page, fileName: string) {
await expect( const link = page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first();
page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first() await expect
).toBeVisible({ timeout: 10000 }); .poll(async () => link.count(), { timeout: 20000 })
.toBeGreaterThan(0);
await expect(link).toBeVisible({ timeout: 15000 });
} }
// Assert a document link containing the given filename does NOT exist // Assert a document link containing the given filename does NOT exist
@ -334,6 +348,7 @@ export async function expectNoDocumentLink(page: Page, fileName: string) {
export async function uploadFiles(page: Page, ...fileNames: string[]) { export async function uploadFiles(page: Page, ...fileNames: string[]) {
for (const name of fileNames) { for (const name of fileNames) {
await uploadFile(page, name); await uploadFile(page, name);
await expectDocumentListed(page, name);
} }
} }

View file

@ -1,22 +1,15 @@
import { test, expect } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
import { import {
setupTest, setupTest,
playTTSAndWaitForASecond, playTTSAndWaitForASecond,
pauseTTSAndVerify,
openVoicesMenu, openVoicesMenu,
selectVoiceAndAssertPlayback, selectVoiceAndAssertPlayback,
expectMediaState, expectMediaState,
expectProcessingTransition, expectProcessingTransition,
} from './helpers'; } from './helpers';
// Single-spec helpers kept local instead of living in shared helpers: async function openSpeedPopover(page: Page) {
async function pauseTTSAndVerify(page: any) {
// Click pause to stop playback
await page.getByRole('button', { name: 'Pause' }).click();
// Check for play button to be visible
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
}
async function openSpeedPopover(page: any) {
const ttsbar = page.locator('[data-app-ttsbar]'); const ttsbar = page.locator('[data-app-ttsbar]');
const buttons = ttsbar.getByRole('button'); const buttons = ttsbar.getByRole('button');
// Heuristic: the Speed control is the first button in the TTS bar and shows something like "1x" // Heuristic: the Speed control is the first button in the TTS bar and shows something like "1x"
@ -27,7 +20,7 @@ async function openSpeedPopover(page: any) {
await page.waitForSelector('input[type="range"]', { timeout: 10000 }); await page.waitForSelector('input[type="range"]', { timeout: 10000 });
} }
async function changeNativeSpeedAndAssert(page: any, newSpeed: number) { async function changeNativeSpeedAndAssert(page: Page, newSpeed: number) {
await openSpeedPopover(page); await openSpeedPopover(page);
const slider = page.locator('input[type="range"]').first(); const slider = page.locator('input[type="range"]').first();