diff --git a/frontend/__tests__/test/pace-caret.spec.ts b/frontend/__tests__/test/pace-caret.spec.ts new file mode 100644 index 000000000000..bce24871d59a --- /dev/null +++ b/frontend/__tests__/test/pace-caret.spec.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const caretMock = vi.hoisted(() => ({ + hidden: true, + goTo: vi.fn(), +})); + +vi.mock("../../src/ts/elements/caret", () => ({ + Caret: class { + goTo = caretMock.goTo; + hide(): void { + caretMock.hidden = true; + } + show(): void { + caretMock.hidden = false; + } + isHidden(): boolean { + return caretMock.hidden; + } + stopAllAnimations = vi.fn(); + clearMargins = vi.fn(); + setStyle = vi.fn(); + }, +})); + +vi.mock("../../src/ts/utils/dom", () => ({ qsr: () => null })); +vi.mock("../../src/ts/db", () => ({ getLocalPB: () => undefined })); +vi.mock("../../src/ts/collections/tags", () => ({ getActiveTagsPB: () => 0 })); +vi.mock("../../src/ts/collections/results", () => ({ + getUserAverage10Once: async () => ({ wpm: 0 }), + getUserDailyBestOnce: async () => ({ wpm: 0 }), +})); +vi.mock("../../src/ts/test/funbox/list", () => ({ + getActiveFunboxes: () => [], +})); +vi.mock("../../src/ts/events/config", () => ({ + configEvent: { subscribe: () => undefined }, +})); +vi.mock("../../src/ts/utils/misc", () => ({ getMode2: () => "10" })); +vi.mock("../../src/ts/config/store", () => ({ + Config: { + paceCaret: "custom", + paceCaretCustomSpeed: 60, + paceCaretStyle: "default", + blindMode: false, + mode: "words", + }, +})); +vi.mock("../../src/ts/states/test", () => ({ + isDirectionReversed: () => false, + isLanguageRightToLeft: () => false, + getActiveWordIndex: () => 0, + getCurrentQuote: () => null, + getResultVisible: () => false, + isPaceRepeat: () => false, + isTestActive: () => true, + setPaceCaretWpm: () => undefined, +})); + +import { words } from "../../src/ts/test/test-words"; +import * as PaceCaret from "../../src/ts/test/pace-caret"; + +// 60 wpm = 300 chars per minute = one step every 200ms +// start() takes the first step right away, so steps(n) means n + 1 steps total +const STEP = 200; + +async function steps(n: number): Promise { + for (let i = 0; i < n; i++) { + await vi.advanceTimersByTimeAsync(STEP); + } +} + +function lastPosition(): { wordIndex: number; letterIndex: number } { + const call = caretMock.goTo.mock.lastCall?.[0] as { + wordIndex: number; + letterIndex: number; + }; + return { wordIndex: call.wordIndex, letterIndex: call.letterIndex }; +} + +describe("pace-caret", () => { + beforeEach(async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + words.reset(); + caretMock.hidden = true; + caretMock.goTo.mockClear(); + words.push("one ", 0); + words.push("two ", 0); + words.push("three ", 0); + await PaceCaret.init(); + PaceCaret.start(); + }); + + afterEach(() => { + PaceCaret.reset(); + vi.useRealTimers(); + }); + + it("steps through generated words", async () => { + await steps(2); + expect(lastPosition()).toEqual({ wordIndex: 0, letterIndex: 3 }); + await steps(1); + expect(lastPosition()).toEqual({ wordIndex: 1, letterIndex: 0 }); + }); + + it("hides when it runs past the generated words", async () => { + await steps(13); + expect(caretMock.hidden).toBe(false); + await steps(1); + expect(caretMock.hidden).toBe(true); + }); + + it("comes back at the right spot once more words are generated", async () => { + await steps(20); + expect(caretMock.hidden).toBe(true); + + words.push("four ", 0); + words.push("five ", 0); + await steps(1); + + expect(caretMock.hidden).toBe(false); + // 22 steps: 4 + 4 + 6 for the first three words, 5 for "four", 3 into "five" + expect(lastPosition()).toEqual({ wordIndex: 4, letterIndex: 3 }); + }); + + it("keeps stepping after coming back", async () => { + await steps(20); + words.push("four ", 0); + words.push("five ", 0); + await steps(2); + expect(lastPosition()).toEqual({ wordIndex: 4, letterIndex: 4 }); + }); +}); diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 55b21fe43e3a..15102bccd272 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -31,6 +31,7 @@ type Settings = { currentLetterIndex: number; wordsStatus: Record; timeout: NodeJS.Timeout | null; + skipped: number; }; let startTimestamp = 0; @@ -118,6 +119,7 @@ export async function init(): Promise { currentLetterIndex: 0, wordsStatus: {}, timeout: null, + skipped: 0, }; setPaceCaretWpm(wpm); } @@ -128,45 +130,45 @@ export async function update(expectedStepEnd: number): Promise { return; } - if (caret.isHidden()) { - caret.show(); - } - - incrementLetterIndex(); - - try { - const now = performance.now(); - const absoluteStepEnd = startTimestamp + expectedStepEnd; - const duration = absoluteStepEnd - now; + const now = performance.now(); + const absoluteStepEnd = startTimestamp + expectedStepEnd; + const duration = absoluteStepEnd - now; - caret.goTo({ - wordIndex: currentSettings.currentWordIndex, - letterIndex: currentSettings.currentLetterIndex, - isLanguageRightToLeft: isLanguageRightToLeft(), - isDirectionReversed: isDirectionReversed(), - animate: true, - animationOptions: { - duration, - easing: "linear", - }, - }); + if (incrementLetterIndex()) { + if (caret.isHidden()) { + caret.show(); + } - currentSettings.timeout = setTimeout( - () => { - if (settings !== currentSettings) return; - update(expectedStepEnd + (currentSettings.spc ?? 0) * 1000).catch( - () => { - if (settings === currentSettings) settings = null; - }, - ); - }, - Math.max(0, duration), - ); - } catch (e) { - console.error(e); + try { + caret.goTo({ + wordIndex: currentSettings.currentWordIndex, + letterIndex: currentSettings.currentLetterIndex, + isLanguageRightToLeft: isLanguageRightToLeft(), + isDirectionReversed: isDirectionReversed(), + animate: true, + animationOptions: { + duration, + easing: "linear", + }, + }); + } catch (e) { + console.error(e); + caret.hide(); + return; + } + } else { caret.hide(); - return; } + + currentSettings.timeout = setTimeout( + () => { + if (settings !== currentSettings) return; + update(expectedStepEnd + (currentSettings.spc ?? 0) * 1000).catch(() => { + if (settings === currentSettings) settings = null; + }); + }, + Math.max(0, duration), + ); } export function reset(): void { @@ -177,20 +179,30 @@ export function reset(): void { startTimestamp = 0; } -function incrementLetterIndex(): void { - if (settings === null) return; +function incrementLetterIndex(): boolean { + if (settings === null) return false; + + const before = { + currentWordIndex: settings.currentWordIndex, + currentLetterIndex: settings.currentLetterIndex, + correction: settings.correction, + skipped: settings.skipped, + }; try { - if ( - settings.currentLetterIndex >= - // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined - TestWords.words.get(settings.currentWordIndex)!.text.length - ) { - //go to the next word - settings.currentLetterIndex = -1; - settings.currentWordIndex++; + for (let i = 0; i <= settings.skipped; i++) { + if ( + settings.currentLetterIndex >= + // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined + TestWords.words.get(settings.currentWordIndex)!.text.length + ) { + //go to the next word + settings.currentLetterIndex = -1; + settings.currentWordIndex++; + } + settings.currentLetterIndex++; } - settings.currentLetterIndex++; + settings.skipped = 0; if (!Config.blindMode) { if (settings.correction < 0) { @@ -221,12 +233,14 @@ function incrementLetterIndex(): void { } } } + return true; } catch (e) { - //out of words - settings = null; - console.log("pace caret out of words"); - caret.hide(); - return; + //out of words, they might still get generated so keep counting steps + settings.currentWordIndex = before.currentWordIndex; + settings.currentLetterIndex = before.currentLetterIndex; + settings.correction = before.correction; + settings.skipped = before.skipped + 1; + return false; } }