Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions frontend/__tests__/test/pace-caret.spec.ts
Original file line number Diff line number Diff line change
@@ -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);

Comment on lines +64 to +65
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This recovery test doesn’t exercise the new timer-based retry path: it manually calls PaceCaret.update(0) again after pushing a word, instead of letting the scheduled retry fire. To validate the fix end-to-end, assert a retry timer was scheduled after the first failure and then advance/run fake timers (and flush pending promises) to confirm the caret state recovers via the scheduled update.

Suggested change
await PaceCaret.update(0);
expect(vi.getTimerCount()).toBeGreaterThan(0);
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();

Copilot uses AI. Check for mistakes.
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();
});
});
103 changes: 83 additions & 20 deletions frontend/src/ts/test/pace-caret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -139,12 +141,26 @@ export async function update(expectedStepEnd: number): Promise<void> {
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;
Expand All @@ -162,24 +178,65 @@ export async function update(expectedStepEnd: number): Promise<void> {
},
});

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();
return;
}
}

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") {
Comment on lines +209 to +214
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

areAllTestWordsGenerated() duplicates TestLogic.areAllTestWordsGenerated() logic (frontend/src/ts/test/test-logic.ts:572-590). This risks drift if generation/limit rules change. Consider extracting this predicate into a small shared module (importable by both) or otherwise centralizing it so pace-caret and test-logic stay consistent.

Copilot uses AI. Check for mistakes.
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);
Expand All @@ -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++;
Expand Down Expand Up @@ -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 {
Expand Down
Loading