From 467fe808529c5ba56bc68481c2f45c34c084ffdf Mon Sep 17 00:00:00 2001 From: Anurag Kejriwal Date: Sun, 1 Mar 2026 21:07:14 -0500 Subject: [PATCH] fix(test): handle pace caret exhaustion without endless retries (@anuragkej) --- frontend/__tests__/test/pace-caret.spec.ts | 101 ++++++++++++++++++++ frontend/src/ts/test/pace-caret.ts | 103 +++++++++++++++++---- 2 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 frontend/__tests__/test/pace-caret.spec.ts diff --git a/frontend/__tests__/test/pace-caret.spec.ts b/frontend/__tests__/test/pace-caret.spec.ts new file mode 100644 index 000000000000..a424e1bd6a0d --- /dev/null +++ b/frontend/__tests__/test/pace-caret.spec.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { __testing as ConfigTesting } from "../../src/ts/config"; +import * as PaceCaret from "../../src/ts/test/pace-caret"; +import * as TestState from "../../src/ts/test/test-state"; +import * as TestWords from "../../src/ts/test/test-words"; + +describe("pace-caret", () => { + beforeEach(() => { + vi.useFakeTimers(); + ConfigTesting.replaceConfig({ + paceCaret: "custom", + paceCaretCustomSpeed: 100, + blindMode: false, + }); + TestState.setActive(true); + TestState.setResultVisible(false); + TestWords.words.reset(); + PaceCaret.reset(); + }); + + afterEach(() => { + PaceCaret.reset(); + TestWords.words.reset(); + TestState.setActive(false); + TestState.setResultVisible(false); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("recovers when the pace caret reaches words that are not generated yet", async () => { + TestWords.words.push("alpha", 0); + await PaceCaret.init(); + + const initialSettings = PaceCaret.settings; + expect(initialSettings).not.toBeNull(); + if (initialSettings === null) { + throw new Error("Pace caret settings were not initialized"); + } + + const showMock = vi.spyOn(PaceCaret.caret, "show"); + const hideMock = vi.spyOn(PaceCaret.caret, "hide"); + vi.spyOn(PaceCaret.caret, "isHidden").mockReturnValue(true); + vi.spyOn(PaceCaret.caret, "goTo").mockImplementation(() => undefined); + + const endOfFirstWord = TestWords.words.get(0)?.length ?? 1; + initialSettings.currentWordIndex = 0; + initialSettings.currentLetterIndex = endOfFirstWord; + initialSettings.correction = 1; + + await PaceCaret.update(0); + + expect(PaceCaret.settings).not.toBeNull(); + expect(PaceCaret.settings?.currentWordIndex).toBe(0); + expect(PaceCaret.settings?.currentLetterIndex).toBe(endOfFirstWord); + expect(PaceCaret.settings?.correction).toBe(1); + expect(hideMock).toHaveBeenCalled(); + expect(showMock).not.toHaveBeenCalled(); + + if (PaceCaret.settings !== null) { + PaceCaret.settings.correction = 0; + } + TestWords.words.push("beta", 1); + await PaceCaret.update(0); + + expect(PaceCaret.settings?.currentWordIndex).toBe(1); + expect(PaceCaret.settings?.currentLetterIndex).toBe(0); + expect(showMock).toHaveBeenCalledTimes(1); + }); + + it("stops retrying when no additional words can be generated", async () => { + ConfigTesting.replaceConfig({ + paceCaret: "custom", + paceCaretCustomSpeed: 100, + blindMode: false, + mode: "words", + words: 1, + }); + TestWords.words.push("alpha", 0); + await PaceCaret.init(); + + const currentSettings = PaceCaret.settings; + expect(currentSettings).not.toBeNull(); + if (currentSettings === null) { + throw new Error("Pace caret settings were not initialized"); + } + + vi.spyOn(PaceCaret.caret, "isHidden").mockReturnValue(true); + vi.spyOn(PaceCaret.caret, "goTo").mockImplementation(() => undefined); + + const endOfFirstWord = TestWords.words.get(0)?.length ?? 1; + currentSettings.currentWordIndex = 0; + currentSettings.currentLetterIndex = endOfFirstWord; + currentSettings.correction = 1; + + await PaceCaret.update(0); + + expect(vi.getTimerCount()).toBe(0); + expect(PaceCaret.settings).toBeNull(); + }); +}); diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index da0b4d01cd40..88e00a5b5e49 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -3,6 +3,8 @@ import Config from "../config"; import * as DB from "../db"; import * as Misc from "../utils/misc"; import * as TestState from "./test-state"; +import * as CustomText from "./custom-text"; +import * as WordsGenerator from "./words-generator"; import * as ConfigEvent from "../observables/config-event"; import { getActiveFunboxes } from "./funbox/list"; import { Caret } from "../utils/caret"; @@ -139,12 +141,26 @@ export async function update(expectedStepEnd: number): Promise { return; } + const nextExpectedStepEnd = + expectedStepEnd + (currentSettings.spc ?? 0) * 1000; + + if (!incrementLetterIndex()) { + if (shouldRetryWhenWordsMayStillGenerate(currentSettings)) { + scheduleUpdate( + currentSettings, + nextExpectedStepEnd, + Math.max(16, (currentSettings.spc ?? 0) * 1000), + ); + } else { + settings = null; + } + return; + } + if (caret.isHidden()) { caret.show(); } - incrementLetterIndex(); - try { const now = performance.now(); const absoluteStepEnd = startTimestamp + expectedStepEnd; @@ -162,17 +178,7 @@ export async function update(expectedStepEnd: number): Promise { }, }); - currentSettings.timeout = setTimeout( - () => { - if (settings !== currentSettings) return; - update(expectedStepEnd + (currentSettings.spc ?? 0) * 1000).catch( - () => { - if (settings === currentSettings) settings = null; - }, - ); - }, - Math.max(0, duration), - ); + scheduleUpdate(currentSettings, nextExpectedStepEnd, Math.max(0, duration)); } catch (e) { console.error(e); caret.hide(); @@ -180,6 +186,57 @@ export async function update(expectedStepEnd: number): Promise { } } +function scheduleUpdate( + currentSettings: Settings, + nextExpectedStepEnd: number, + delay: number, +): void { + currentSettings.timeout = setTimeout(() => { + if (settings !== currentSettings) return; + update(nextExpectedStepEnd).catch(() => { + if (settings === currentSettings) settings = null; + }); + }, delay); +} + +function shouldRetryWhenWordsMayStillGenerate( + currentSettings: Settings, +): boolean { + if (settings !== currentSettings) return false; + return !areAllTestWordsGenerated(); +} + +function areAllTestWordsGenerated(): boolean { + if (Config.mode === "words") { + return TestWords.words.length >= Config.words && Config.words > 0; + } + + if (Config.mode === "quote") { + return ( + TestWords.words.length >= (TestWords.currentQuote?.textSplit?.length ?? 0) + ); + } + + if (Config.mode === "custom") { + const limitMode = CustomText.getLimitMode(); + const limitValue = CustomText.getLimitValue(); + + if (limitMode === "word") { + return TestWords.words.length >= limitValue && limitValue !== 0; + } + + if (limitMode === "section") { + return ( + WordsGenerator.sectionIndex >= limitValue && + WordsGenerator.currentSection.length === 0 && + limitValue !== 0 + ); + } + } + + return false; +} + export function reset(): void { if (settings?.timeout !== null && settings?.timeout !== undefined) { clearTimeout(settings.timeout); @@ -188,8 +245,12 @@ export function reset(): void { startTimestamp = 0; } -function incrementLetterIndex(): void { - if (settings === null) return; +function incrementLetterIndex(): boolean { + if (settings === null) return false; + + const previousWordIndex = settings.currentWordIndex; + const previousLetterIndex = settings.currentLetterIndex; + const previousCorrection = settings.correction; try { settings.currentLetterIndex++; @@ -228,13 +289,15 @@ function incrementLetterIndex(): void { } } } - } catch (e) { - //out of words - settings = null; - console.log("pace caret out of words"); + } catch { + settings.currentWordIndex = previousWordIndex; + settings.currentLetterIndex = previousLetterIndex; + settings.correction = previousCorrection; caret.hide(); - return; + return false; } + + return true; } export function handleSpace(correct: boolean, currentWord: string): void {