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 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([]);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue