Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
467fe80
fix(test): handle pace caret exhaustion without endless retries (@anu…
anuragkej Mar 2, 2026
703e77a
fix: xp bar background
Miodec Mar 1, 2026
09c5f7a
refactor: solid dev options modal (@miodec) (#7561)
Miodec Mar 2, 2026
d6f889a
chore: add workflow to check for added todos in a pr
Miodec Mar 2, 2026
6afbe56
fix: permissions to post comments
Miodec Mar 2, 2026
78ba9a9
impr: use data-nav-item to address navigation items (@fehmer) (#7558)
fehmer Mar 2, 2026
a4b191a
refactor: enhance Conditional component to support generic types
Miodec Mar 3, 2026
3c481a6
chore: add callback ref to Anime component for DOM access
Miodec Mar 3, 2026
1b97d96
feat: implement createEvent hook for reactive event handling
Miodec Mar 3, 2026
fa92b31
feat: implement createSignalWithSetters for enhanced signal management
Miodec Mar 3, 2026
94aa3da
feat: add AnimeConditional, AnimeShow, AnimeSwitch, and AnimeMatch
Miodec Mar 3, 2026
8fd59da
chore: reenable explicit-return-type for ts files
Miodec Mar 4, 2026
8baa10a
refactor: solid header (@miodec, @fehmer) (#7564)
Miodec Mar 4, 2026
5c7997e
chore: add claude.md
Miodec Mar 4, 2026
235bc42
chore: enhance button styling
Miodec Mar 4, 2026
45dd3be
chore: update claude.md
Miodec Mar 4, 2026
a9f5828
fix: streamline variant handling in Button component
Miodec Mar 4, 2026
52f32d7
chore: update claude.md
Miodec Mar 4, 2026
b8eadf2
chore: update claude.md
Miodec Mar 4, 2026
d96fd92
chore: add format-and-lint hook for code formatting and linting
Miodec Mar 5, 2026
19bdc47
refactor: add Balloon component
Miodec Mar 5, 2026
e64b286
chore: change balloon position depending on width
Miodec Mar 5, 2026
e9c0483
refactor: dont * import
Miodec Mar 5, 2026
e8b632a
chore: add useRef hook
Miodec Mar 5, 2026
258633e
refactor: convert register captcha modal to solid
Miodec Mar 5, 2026
21657ce
chore: fix throw on signout
Miodec Mar 5, 2026
d280816
style: lower opacity for accuracy in PbTable component
Miodec Mar 5, 2026
8d98692
chore: add claude review skill
Miodec Mar 5, 2026
03e3d04
fix: use has-focus-visible for account menu and remove redundant poin…
Miodec Mar 5, 2026
06292ba
chore: add claude commit skill
Miodec Mar 5, 2026
a0ec026
chore: update claude.md and edit hook
Miodec Mar 5, 2026
8dbe91b
refactor: solid notifications (@miodec) (#7576)
Miodec Mar 5, 2026
61e6c6f
chore: also run test file if it exists
Miodec Mar 5, 2026
2376b22
fix: bad typecast converting null to object
Miodec Mar 6, 2026
abc8a3a
refactor: solid leaderboards (@fehmer, @miodec) (#7485)
fehmer Mar 7, 2026
420b656
fix(ui): disable UI interaction during loading state (@openvaibhav) (…
openvaibhav Mar 8, 2026
d7a8525
refactor: solid alerts (@miodec, @fehmer) (#7567)
fehmer Mar 8, 2026
dc5458d
fix(profile): show additional user badges in one line (@fehmer) (#7568)
fehmer Mar 8, 2026
8edaee3
chore: add frontend storybook (@miodec) (#7591)
Miodec Mar 8, 2026
31e62c1
fix: badge styling, avatar styling
Miodec Mar 8, 2026
3317492
chore: release v26.11.0
Miodec Mar 8, 2026
f68599c
fix(profile): fix style issues (@fehmer) (#7593)
fehmer Mar 8, 2026
1315575
fix(User component): spinner positioning, level spacing
Miodec Mar 8, 2026
fd98d42
fix(profile): badge text hiding on narrow screen
Miodec Mar 8, 2026
bfee09c
fix: stupid safari
Miodec Mar 8, 2026
c4428aa
Merge branch 'master' into fix/7527-pace-caret-plus-three
Miodec Mar 9, 2026
c563655
fix(test): keep pace caret timing accurate after retry recovery (@anu…
anuragkej Mar 10, 2026
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
138 changes: 138 additions & 0 deletions frontend/__tests__/test/pace-caret.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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);

expect(vi.getTimerCount()).toBeGreaterThan(0);
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();

expect(PaceCaret.settings?.currentWordIndex).toBe(1);
expect(PaceCaret.settings?.currentLetterIndex).toBe(0);
expect(showMock).toHaveBeenCalledTimes(1);
});

it("catches up retry timing when schedule is already behind", async () => {
ConfigTesting.replaceConfig({
paceCaret: "custom",
paceCaretCustomSpeed: 60,
blindMode: false,
mode: "time",
time: 30,
});
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");
}

const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
vi.spyOn(performance, "now").mockReturnValue(1000);
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(setTimeoutSpy).toHaveBeenCalled();
const retryDelay = setTimeoutSpy.mock.calls.at(-1)?.[1];
expect(retryDelay).toBe(16);
});

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();
});
});
111 changes: 88 additions & 23 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,16 +141,28 @@ 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, getDelayUntilStepEnd(nextExpectedStepEnd)),
);
} else {
settings = null;
}
return;
}

if (caret.isHidden()) {
caret.show();
}

incrementLetterIndex();

try {
const now = performance.now();
const absoluteStepEnd = startTimestamp + expectedStepEnd;
const duration = absoluteStepEnd - now;
const duration = getDelayUntilStepEnd(expectedStepEnd);

caret.goTo({
wordIndex: currentSettings.currentWordIndex,
Expand All @@ -162,24 +176,69 @@ 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 getDelayUntilStepEnd(stepEnd: number): number {
return startTimestamp + stepEnd - performance.now();
}

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);
Expand All @@ -188,8 +247,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 +291,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