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:
parent
b600b67252
commit
4a985b3bf1
2 changed files with 67 additions and 5 deletions
|
|
@ -100,6 +100,10 @@ interface SetTextOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTINUATION_LOOKAHEAD = 600;
|
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 SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/;
|
||||||
const wordHighlightFeatureEnabled =
|
const wordHighlightFeatureEnabled =
|
||||||
process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false';
|
process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false';
|
||||||
|
|
@ -111,6 +115,15 @@ const SILENT_WAV_DATA_URI =
|
||||||
const normalizeLocationKey = (location: TTSLocation) =>
|
const normalizeLocationKey = (location: TTSLocation) =>
|
||||||
typeof location === 'number' ? `num:${location}` : `str:${location}`;
|
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 isWhitespaceChar = (char: string) => /\s/.test(char);
|
||||||
|
|
||||||
const skipWhitespace = (source: string, start: number) => {
|
const skipWhitespace = (source: string, start: number) => {
|
||||||
|
|
@ -221,7 +234,10 @@ const extractContinuationSlice = (nextText: string): TTSSmartMergeResult | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawSlice = snippet.slice(0, boundaryIndex);
|
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) {
|
if (!addition) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -229,7 +245,7 @@ const extractContinuationSlice = (nextText: string): TTSSmartMergeResult | null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: addition,
|
text: addition,
|
||||||
carried: rawSlice,
|
carried: addition,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -388,6 +404,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const epubContinuationRef = useRef<string | null>(null);
|
const epubContinuationRef = useRef<string | null>(null);
|
||||||
const pageTurnEstimateRef = useRef<TTSPageTurnEstimate | null>(null);
|
const pageTurnEstimateRef = useRef<TTSPageTurnEstimate | null>(null);
|
||||||
const pageTurnTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 sentenceAlignmentCacheRef = useRef<Map<string, TTSSentenceAlignment>>(new Map());
|
||||||
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);
|
||||||
|
|
@ -587,6 +604,35 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
// Handle within current page bounds
|
// Handle within current page bounds
|
||||||
if (nextIndex < sentences.length && nextIndex >= 0) {
|
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);
|
setCurrentIndex(nextIndex);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -769,6 +815,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
return;
|
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
|
// Set all state updates in a predictable order
|
||||||
setSentences(newSentences);
|
setSentences(newSentences);
|
||||||
|
|
||||||
|
|
@ -1667,6 +1723,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
locationChangeHandlerRef.current = null;
|
locationChangeHandlerRef.current = null;
|
||||||
epubContinuationRef.current = null;
|
epubContinuationRef.current = null;
|
||||||
continuationCarryRef.current.clear();
|
continuationCarryRef.current.clear();
|
||||||
|
pageFirstBlockFingerprintRef.current.clear();
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setSentences([]);
|
setSentences([]);
|
||||||
|
|
|
||||||
|
|
@ -234,12 +234,17 @@ export async function setupTest(page: Page, testInfo?: TestInfo) {
|
||||||
// privacy modal, so open Settings explicitly if onboarding did not auto-open.
|
// privacy modal, so open Settings explicitly if onboarding did not auto-open.
|
||||||
const settingsDialog = page.getByRole('dialog', { name: 'Settings' });
|
const settingsDialog = page.getByRole('dialog', { name: 'Settings' });
|
||||||
const saveBtn = settingsDialog.getByRole('button', { name: 'Save' });
|
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' });
|
const settingsBtn = page.getByRole('button', { name: 'Settings' });
|
||||||
if (await settingsBtn.isVisible().catch(() => false)) {
|
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 });
|
await expect(saveBtn).toBeVisible({ timeout: 10000 });
|
||||||
// SettingsModal can briefly disable Save while it mirrors a custom model into the input field.
|
// SettingsModal can briefly disable Save while it mirrors a custom model into the input field.
|
||||||
await expect(saveBtn).toBeEnabled({ timeout: 15000 });
|
await expect(saveBtn).toBeEnabled({ timeout: 15000 });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue