feat(tts): add continuation loop guard and block fingerprinting

Introduce safeguards for continuation handling in TTS flow to prevent looping and over-carrying text. Add constants to limit carried characters and words, and loop-guard thresholds. Implement normalizeBlockFingerprint to create compact fingerprints of page-first blocks and store them in pageFirstBlockFingerprintRef. Use fingerprint comparison and progress threshold to skip to next page when a loop is detected. Cap continuation slices by characters and words and ensure carried text matches the returned addition.

test(ui): make settings save button click more robust

In tests/helpers.ts wait for the Settings Save button to become visible (with a short timeout) before attempting to open Settings, and click the Settings button with force to avoid intermittent pointer-interception failures. Improve stability of setupTest by handling enter transitions and overlays.
This commit is contained in:
Richard R 2026-04-04 14:50:34 -06:00
parent b600b67252
commit 4a985b3bf1
2 changed files with 67 additions and 5 deletions

View file

@ -100,6 +100,10 @@ interface SetTextOptions {
}
const CONTINUATION_LOOKAHEAD = 600;
const MAX_CONTINUATION_CARRY_CHARS = 220;
const MAX_CONTINUATION_CARRY_WORDS = 40;
const LOOP_GUARD_MIN_INDEX = 2;
const LOOP_GUARD_MIN_PROGRESS = 0.6;
const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/;
const wordHighlightFeatureEnabled =
process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false';
@ -111,6 +115,15 @@ const SILENT_WAV_DATA_URI =
const normalizeLocationKey = (location: TTSLocation) =>
typeof location === 'number' ? `num:${location}` : `str:${location}`;
const normalizeBlockFingerprint = (text: string): string => {
const normalized = preprocessSentenceForAudio(text)
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return normalized.slice(0, 200);
};
const isWhitespaceChar = (char: string) => /\s/.test(char);
const skipWhitespace = (source: string, start: number) => {
@ -221,7 +234,10 @@ const extractContinuationSlice = (nextText: string): TTSSmartMergeResult | null
}
const rawSlice = snippet.slice(0, boundaryIndex);
const addition = rawSlice.trim();
const charsCappedSlice = rawSlice.slice(0, MAX_CONTINUATION_CARRY_CHARS);
const words = charsCappedSlice.trim().split(/\s+/).filter(Boolean);
const wordCapped = words.slice(0, MAX_CONTINUATION_CARRY_WORDS).join(' ');
const addition = wordCapped.trim();
if (!addition) {
return null;
@ -229,7 +245,7 @@ const extractContinuationSlice = (nextText: string): TTSSmartMergeResult | null
return {
text: addition,
carried: rawSlice,
carried: addition,
};
};
@ -388,6 +404,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const epubContinuationRef = useRef<string | null>(null);
const pageTurnEstimateRef = useRef<TTSPageTurnEstimate | null>(null);
const pageTurnTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pageFirstBlockFingerprintRef = useRef<Map<string, string>>(new Map());
const sentenceAlignmentCacheRef = useRef<Map<string, TTSSentenceAlignment>>(new Map());
const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState<TTSSentenceAlignment | undefined>();
const [currentWordIndex, setCurrentWordIndex] = useState<number | null>(null);
@ -587,6 +604,35 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Handle within current page bounds
if (nextIndex < sentences.length && nextIndex >= 0) {
if (
!isEPUB &&
!backwards &&
currDocPages !== undefined &&
currDocPageNumber < currDocPages &&
nextIndex >= LOOP_GUARD_MIN_INDEX &&
sentences.length > 0
) {
const locationKey = normalizeLocationKey(currDocPageNumber);
const cachedFirstFingerprint = pageFirstBlockFingerprintRef.current.get(locationKey)
?? normalizeBlockFingerprint(sentences[0] || '');
const nextFingerprint = normalizeBlockFingerprint(sentences[nextIndex] || '');
const progress = sentences.length > 1 ? nextIndex / (sentences.length - 1) : 0;
if (
cachedFirstFingerprint &&
nextFingerprint &&
cachedFirstFingerprint === nextFingerprint &&
progress >= LOOP_GUARD_MIN_PROGRESS
) {
const targetLocation = currDocPageNumber + 1;
prefetchedLocationTextRef.current.delete(normalizeLocationKey(targetLocation));
pendingNextLocationRef.current = targetLocation;
continuationCarryRef.current.delete(normalizeLocationKey(targetLocation));
skipToLocation(targetLocation);
return;
}
}
setCurrentIndex(nextIndex);
return;
}
@ -769,6 +815,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
return;
}
if (!isEPUB && typeof normalizedOptions.location === 'number') {
const firstFingerprint = normalizeBlockFingerprint(newSentences[0] || '');
if (firstFingerprint) {
pageFirstBlockFingerprintRef.current.set(
normalizeLocationKey(normalizedOptions.location),
firstFingerprint
);
}
}
// Set all state updates in a predictable order
setSentences(newSentences);
@ -1667,6 +1723,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
locationChangeHandlerRef.current = null;
epubContinuationRef.current = null;
continuationCarryRef.current.clear();
pageFirstBlockFingerprintRef.current.clear();
setIsPlaying(false);
setCurrentIndex(0);
setSentences([]);

View file

@ -234,12 +234,17 @@ export async function setupTest(page: Page, testInfo?: TestInfo) {
// privacy modal, so open Settings explicitly if onboarding did not auto-open.
const settingsDialog = page.getByRole('dialog', { name: 'Settings' });
const saveBtn = settingsDialog.getByRole('button', { name: 'Save' });
if (!(await saveBtn.isVisible().catch(() => false))) {
// On some local runs, Settings opens automatically but the Save button is not yet
// visible during enter transition. Prefer waiting for it before trying to click.
await saveBtn.waitFor({ state: 'visible', timeout: 2500 }).catch(async () => {
const settingsBtn = page.getByRole('button', { name: 'Settings' });
if (await settingsBtn.isVisible().catch(() => false)) {
await settingsBtn.click();
// Force avoids occasional pointer interception by in-flight dialog overlays.
await settingsBtn.click({ force: true });
}
}
});
await expect(saveBtn).toBeVisible({ timeout: 10000 });
// SettingsModal can briefly disable Save while it mirrors a custom model into the input field.
await expect(saveBtn).toBeEnabled({ timeout: 15000 });