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
133 changes: 133 additions & 0 deletions frontend/__tests__/test/pace-caret.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 });
});
});
116 changes: 65 additions & 51 deletions frontend/src/ts/test/pace-caret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type Settings = {
currentLetterIndex: number;
wordsStatus: Record<number, true | undefined>;
timeout: NodeJS.Timeout | null;
skipped: number;
};

let startTimestamp = 0;
Expand Down Expand Up @@ -118,6 +119,7 @@ export async function init(): Promise<void> {
currentLetterIndex: 0,
wordsStatus: {},
timeout: null,
skipped: 0,
};
setPaceCaretWpm(wpm);
}
Expand All @@ -128,45 +130,45 @@ export async function update(expectedStepEnd: number): Promise<void> {
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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
}

Expand Down
Loading