From 033c7cf0eccc6ae967bf322e4fcc8135852c43cf Mon Sep 17 00:00:00 2001 From: Jack Date: Fri, 29 May 2026 13:16:07 +0200 Subject: [PATCH 1/5] refactor: move some test state to solid signals (@miodec) (#8025) --- frontend/src/ts/pages/test.ts | 4 +-- frontend/src/ts/states/test.ts | 22 ++++++++++++++-- frontend/src/ts/test/result.ts | 3 ++- frontend/src/ts/test/test-logic.ts | 42 ++++++++++++++++-------------- frontend/src/ts/test/test-stats.ts | 39 ++------------------------- 5 files changed, 48 insertions(+), 62 deletions(-) diff --git a/frontend/src/ts/pages/test.ts b/frontend/src/ts/pages/test.ts index 3abbf34aab78..4e048adbe249 100644 --- a/frontend/src/ts/pages/test.ts +++ b/frontend/src/ts/pages/test.ts @@ -1,4 +1,3 @@ -import * as TestStats from "../test/test-stats"; import * as TestLogic from "../test/test-logic"; import * as Funbox from "../test/funbox/funbox"; import Page from "./page"; @@ -7,6 +6,7 @@ import * as ModesNotice from "../elements/modes-notice"; import * as Keymap from "../elements/keymap"; import { blurInputElement } from "../input/input-element"; import { qsr } from "../utils/dom"; +import { resetIncompleteTests } from "../states/test"; export const page = new Page({ id: "test", @@ -25,7 +25,7 @@ export const page = new Page({ }, beforeShow: async (): Promise => { updateFooterAndVerticalAds(false); - TestStats.resetIncomplete(); + resetIncompleteTests(); TestLogic.restart({ noAnim: true, }); diff --git a/frontend/src/ts/states/test.ts b/frontend/src/ts/states/test.ts index 845c2fda9f9e..0d3347cf37c7 100644 --- a/frontend/src/ts/states/test.ts +++ b/frontend/src/ts/states/test.ts @@ -1,10 +1,12 @@ -import { createSignal, createEffect } from "solid-js"; +import { createSignal, createEffect, createMemo } from "solid-js"; import { Challenge } from "@monkeytype/schemas/challenges"; import { getConfig } from "../config/store"; import { getActivePage } from "./core"; import { canQuickRestart } from "../utils/quick-restart"; import { getData as getCustomTextData } from "../test/custom-text"; import { isCustomTextLong } from "../legacy-states/custom-text-name"; +import { CompletedEvent, IncompleteTest } from "@monkeytype/schemas/results"; +import { createSignalWithSetters } from "../hooks/createSignalWithSetters"; export const [wordsHaveNewline, setWordsHaveNewline] = createSignal(false); export const [wordsHaveTab, setWordsHaveTab] = createSignal(false); @@ -13,8 +15,24 @@ export const [getLoadedChallenge, setLoadedChallenge] = createSignal(null); export const [getResultVisible, setResultVisible] = createSignal(false); export const [getFocus, setFocus] = createSignal(false); - +export const [isTestInvalid, setIsTestInvalid] = createSignal(false); export const [isLongTest, setIsLongTest] = createSignal(false); +export const [getLastResult, setLastResult] = createSignal | null>(null); +export const [ + getIncompleteTests, + { push: pushIncompleteTest, reset: resetIncompleteTests }, +] = createSignalWithSetters([])({ + push: (set, val: IncompleteTest) => set((arr) => [...arr, val]), + reset: (set) => set([]), +}); + +export const getRestartCount = createMemo(() => getIncompleteTests().length); +export const getIncompleteSeconds = createMemo(() => + getIncompleteTests().reduce((sum, test) => sum + test.seconds, 0), +); createEffect(() => { getActivePage(); // depend on active page diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index eb3ca8a262e1..65e7dda1e126 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -61,6 +61,7 @@ import * as ConnectionState from "../legacy-states/connection"; import { currentQuote } from "./test-words"; import { qs, qsa } from "../utils/dom"; import { getTheme } from "../states/theme"; +import { isTestInvalid } from "../states/test"; let result: CompletedEvent; let minChartVal: number; @@ -833,7 +834,7 @@ function updateOther( if (afkDetected) { otherText += "
afk detected"; } - if (TestStats.invalid) { + if (isTestInvalid()) { otherText += "
invalid"; const extra: string[] = []; if ( diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 2d8c14d97cc6..e4fe5d35cd05 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -29,6 +29,13 @@ import { clearQuoteStats } from "../states/quote-rate"; import * as Result from "./result"; import { getActivePage, isAuthenticated } from "../states/core"; import { + getIncompleteSeconds, + getIncompleteTests, + getRestartCount, + pushIncompleteTest, + resetIncompleteTests, + setIsTestInvalid, + setLastResult, setResultVisible, setWordsHaveNewline, setWordsHaveTab, @@ -253,10 +260,8 @@ export function restart(options = {} as RestartOptions): void { const afkseconds = TestStats.calculateAfkSeconds(testSeconds); let tt = Numbers.roundTo2(testSeconds - afkseconds); if (tt < 0) tt = 0; - TestStats.incrementIncompleteSeconds(tt); - TestStats.incrementRestartCount(); const acc = Numbers.roundTo2(TestStats.calculateAccuracy()); - TestStats.pushIncompleteTest(acc, tt); + pushIncompleteTest({ acc, seconds: tt }); } } @@ -297,6 +302,7 @@ export function restart(options = {} as RestartOptions): void { } TestTimer.clear(); + setIsTestInvalid(false); TestStats.restart(); TestInput.restart(); TestInput.corrected.reset(); @@ -846,12 +852,10 @@ function buildCompletedEvent( lazyMode: Config.lazyMode, timestamp: Date.now(), language: language, - restartCount: TestStats.restartCount, - incompleteTests: TestStats.incompleteTests, + restartCount: getRestartCount(), + incompleteTests: getIncompleteTests(), incompleteTestSeconds: - TestStats.incompleteSeconds < 0 - ? 0 - : Numbers.roundTo2(TestStats.incompleteSeconds), + getIncompleteSeconds() < 0 ? 0 : Numbers.roundTo2(getIncompleteSeconds()), difficulty: Config.difficulty, blindMode: Config.blindMode, tags: activeTagsIds, @@ -1013,7 +1017,7 @@ export async function finish(difficultyFailed = false): Promise { const completedEvent = structuredClone(ce) as CompletedEvent; - TestStats.setLastResult(structuredClone(completedEvent)); + setLastResult(structuredClone(completedEvent)); ///////// completed event ready @@ -1036,7 +1040,7 @@ export async function finish(difficultyFailed = false): Promise { ) { showNoticeNotification("Test invalid - inconsistent test duration"); console.error("Test duration inconsistent", ce.testDuration, dateDur); - TestStats.setInvalid(); + setIsTestInvalid(true); dontSave = true; } else if (difficultyFailed) { showNoticeNotification(`Test failed - ${failReason}`, { @@ -1063,16 +1067,16 @@ export async function finish(difficultyFailed = false): Promise { (Config.mode === "zen" && completedEvent.testDuration < 15) ) { showNoticeNotification("Test invalid - too short"); - TestStats.setInvalid(); + setIsTestInvalid(true); tooShort = true; dontSave = true; } else if (afkDetected) { showNoticeNotification("Test invalid - AFK detected"); - TestStats.setInvalid(); + setIsTestInvalid(true); dontSave = true; } else if (TestState.isRepeated) { showNoticeNotification("Test invalid - repeated"); - TestStats.setInvalid(); + setIsTestInvalid(true); dontSave = true; } else if ( completedEvent.wpm < 0 || @@ -1084,7 +1088,7 @@ export async function finish(difficultyFailed = false): Promise { completedEvent.mode2 === "10") ) { showNoticeNotification("Test invalid - wpm"); - TestStats.setInvalid(); + setIsTestInvalid(true); dontSave = true; } else if ( completedEvent.rawWpm < 0 || @@ -1096,7 +1100,7 @@ export async function finish(difficultyFailed = false): Promise { completedEvent.mode2 === "10") ) { showNoticeNotification("Test invalid - raw"); - TestStats.setInvalid(); + setIsTestInvalid(true); dontSave = true; } else if ( (!DB.getSnapshot()?.lbOptOut && @@ -1105,7 +1109,7 @@ export async function finish(difficultyFailed = false): Promise { (completedEvent.acc < 50 || completedEvent.acc > 100)) ) { showNoticeNotification("Test invalid - accuracy"); - TestStats.setInvalid(); + setIsTestInvalid(true); dontSave = true; } @@ -1118,9 +1122,7 @@ export async function finish(difficultyFailed = false): Promise { let tt = Numbers.roundTo2(testSeconds - afkseconds); if (tt < 0) tt = 0; const acc = completedEvent.acc; - TestStats.incrementIncompleteSeconds(tt); - TestStats.incrementRestartCount(); - TestStats.pushIncompleteTest(acc, tt); + pushIncompleteTest({ acc, seconds: tt }); } } @@ -1180,7 +1182,7 @@ export async function finish(difficultyFailed = false): Promise { if (dontSave) { void AnalyticsController.log("testCompletedInvalid"); } else { - TestStats.resetIncomplete(); + resetIncompleteTests(); if (!completedEvent.bailedOut) { const challenge = ChallengeContoller.verify(completedEvent); diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index dd76e07974d1..0cab6f93d704 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -5,9 +5,9 @@ import * as TestInput from "./test-input"; import * as TestWords from "./test-words"; import * as TestState from "./test-state"; import * as Numbers from "@monkeytype/util/numbers"; -import { CompletedEvent, IncompleteTest } from "@monkeytype/schemas/results"; import { isFunboxActiveWithProperty } from "./funbox/list"; import * as CustomText from "./custom-text"; +import { getLastResult } from "../states/test"; type CharCount = { spaces: number; @@ -33,21 +33,14 @@ export type Stats = { correctSpaces: number; }; -export let invalid = false; export let start: number, end: number; export let start2: number, end2: number; export let start3: number, end3: number; export let lastSecondNotRound = false; -export let lastResult: Omit; - -export function setLastResult(result: CompletedEvent): void { - lastResult = result; -} - export function getStats(): unknown { const ret = { - lastResult, + lastResult: getLastResult(), start, end, start3, @@ -106,37 +99,9 @@ export function getStats(): unknown { export function restart(): void { start = 0; end = 0; - invalid = false; lastSecondNotRound = false; } -export let restartCount = 0; -export let incompleteSeconds = 0; - -export let incompleteTests: IncompleteTest[] = []; - -export function incrementRestartCount(): void { - restartCount++; -} - -export function incrementIncompleteSeconds(val: number): void { - incompleteSeconds += val; -} - -export function pushIncompleteTest(acc: number, seconds: number): void { - incompleteTests.push({ acc, seconds }); -} - -export function resetIncomplete(): void { - restartCount = 0; - incompleteSeconds = 0; - incompleteTests = []; -} - -export function setInvalid(): void { - invalid = true; -} - export function calculateTestSeconds(now?: number): number { let duration = (end - start) / 1000; From f9074fb812d574970c74587525af9ba988483e1f Mon Sep 17 00:00:00 2001 From: Jack Date: Fri, 29 May 2026 15:45:26 +0200 Subject: [PATCH 2/5] refactor: test events phase 1 (@miodec) (#8021) Consolidate all data into one array of events, derive all stats from this array. Phase 1 runs the new system in shadow mode, comparing to the original and sending mismatches to the backend. Phase 2 will fully replace the system. --- backend/src/api/controllers/result.ts | 15 + backend/src/api/routes/results.ts | 4 + frontend/__tests__/test/events/data.spec.ts | 539 +++++++++++++ .../__tests__/test/events/helpers.spec.ts | 235 ++++++ frontend/__tests__/test/events/stats.spec.ts | 749 ++++++++++++++++++ frontend/__tests__/utils/strings.spec.ts | 425 ++++++++++ frontend/src/ts/constants/keys.ts | 1 + frontend/src/ts/input/handlers/delete.ts | 11 +- frontend/src/ts/input/handlers/insert-text.ts | 11 + frontend/src/ts/input/handlers/keydown.ts | 19 +- frontend/src/ts/input/handlers/keyup.ts | 9 + .../src/ts/input/listeners/composition.ts | 23 +- frontend/src/ts/input/listeners/input.ts | 2 +- frontend/src/ts/test/events/data.ts | 355 +++++++++ frontend/src/ts/test/events/helpers.ts | 117 +++ frontend/src/ts/test/events/stats.ts | 511 ++++++++++++ frontend/src/ts/test/events/types.ts | 100 +++ frontend/src/ts/test/test-input.ts | 8 +- frontend/src/ts/test/test-logic.ts | 374 ++++++++- frontend/src/ts/test/test-state.ts | 23 + frontend/src/ts/test/test-stats.ts | 11 +- frontend/src/ts/test/test-timer.ts | 37 +- frontend/src/ts/utils/numbers.ts | 8 + frontend/src/ts/utils/strings.ts | 67 ++ packages/contracts/src/rate-limit/index.ts | 5 + packages/contracts/src/results.ts | 23 + 26 files changed, 3663 insertions(+), 19 deletions(-) create mode 100644 frontend/__tests__/test/events/data.spec.ts create mode 100644 frontend/__tests__/test/events/helpers.spec.ts create mode 100644 frontend/__tests__/test/events/stats.spec.ts create mode 100644 frontend/src/ts/test/events/data.ts create mode 100644 frontend/src/ts/test/events/helpers.ts create mode 100644 frontend/src/ts/test/events/stats.ts create mode 100644 frontend/src/ts/test/events/types.ts diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 59396abbd705..a2b500f5a9b3 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -43,6 +43,7 @@ import { GetResultsResponse, UpdateResultTagsRequest, UpdateResultTagsResponse, + ReportCompletedEventMismatchRequest, } from "@monkeytype/contracts/results"; import { CompletedEvent, @@ -184,6 +185,20 @@ export async function updateTags( }); } +export async function reportCompletedEventMismatch( + req: MonkeyRequest, +): Promise { + const { uid } = req.ctx.decodedToken; + const { notMatching } = req.body; + // Logger.warning( + // `Completed event mismatch for uid ${uid}: ${notMatching.join(", ")}`, + // ); + // Logger.warning(`Old CE: ${JSON.stringify(ce)}`); + // Logger.warning(`New CE: ${JSON.stringify(ce2)}`); + void addLog("completed_event_mismatch", { notMatching }, uid); + return new MonkeyResponse("Mismatch reported", null); +} + export async function addResult( req: MonkeyRequest, ): Promise { diff --git a/backend/src/api/routes/results.ts b/backend/src/api/routes/results.ts index 179aa7fc7cea..2f512e7214e1 100644 --- a/backend/src/api/routes/results.ts +++ b/backend/src/api/routes/results.ts @@ -17,6 +17,10 @@ export default s.router(resultsContract, { updateTags: { handler: async (r) => callController(ResultController.updateTags)(r), }, + reportCompletedEventMismatch: { + handler: async (r) => + callController(ResultController.reportCompletedEventMismatch)(r), + }, deleteAll: { handler: async (r) => callController(ResultController.deleteAll)(r), }, diff --git a/frontend/__tests__/test/events/data.spec.ts b/frontend/__tests__/test/events/data.spec.ts new file mode 100644 index 000000000000..14cbaaaccd79 --- /dev/null +++ b/frontend/__tests__/test/events/data.spec.ts @@ -0,0 +1,539 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../../../src/ts/test/test-stats", () => ({ + start: 1000, +})); + +import { + logTestEvent, + getAllTestEvents, + getInputEvents, + getInputEventsPerWord, + cleanupData, + resetTestEvents, + __testing, +} from "../../../src/ts/test/events/data"; +import type { + InputEventData, + KeydownEvent, + KeydownEventData, + KeyupEvent, + KeyupEventData, + TimerEventData, +} from "../../../src/ts/test/events/types"; +import { Keycode } from "../../../src/ts/constants/keys"; + +function keyDown(code: Keycode | "NoCode" = "KeyA"): KeydownEventData { + return { code, ctrl: false, shift: false, alt: false, meta: false }; +} + +function keyUp(code: Keycode | "NoCode" = "KeyA"): KeyupEventData { + return { + code, + ctrl: false, + shift: false, + alt: false, + meta: false, + }; +} + +function inputData( + overrides: Partial<{ + charIndex: number; + wordIndex: number; + data: string; + correct: boolean; + inputType: string; + }> = {}, +): InputEventData { + return { + charIndex: 0, + wordIndex: 0, + inputType: "insertText", + data: "a", + correct: true, + ...overrides, + } as InputEventData; +} + +function timerData( + event: "start" | "step" | "end", + timer: number, +): TimerEventData { + if (event === "step") { + return { event, timer, drift: 0 }; + } + return { event, timer }; +} + +describe("data.ts", () => { + beforeEach(() => { + resetTestEvents(); + __testing.resetPressedKeys(); + }); + + describe("logTestEvent + getAllTestEvents", () => { + it("returns empty array when no events logged", () => { + expect(getAllTestEvents()).toEqual([]); + }); + + it("logs and retrieves events sorted by ms", () => { + logTestEvent("input", 1050, inputData()); + logTestEvent("keydown", 1030, keyDown()); + logTestEvent("timer", 1020, timerData("start", 0)); + + const events = getAllTestEvents(); + expect(events).toHaveLength(3); + expect(events[0]!.type).toBe("timer"); + expect(events[1]!.type).toBe("keydown"); + expect(events[2]!.type).toBe("input"); + }); + + it("input events with the same ms as timer end are kept", () => { + logTestEvent("timer", 1000, timerData("start", 0)); + logTestEvent("timer", 2000, timerData("end", 1)); + logTestEvent("input", 2000, inputData()); + + cleanupData(); + const events = getAllTestEvents(); + const inputs = events.filter((e) => e.type === "input"); + expect(inputs).toHaveLength(1); + }); + + it("computes testMs relative to start", () => { + logTestEvent("timer", 1500, timerData("start", 0)); + const events = getAllTestEvents(); + expect(events[0]!.testMs).toBe(500); // 1500 - 1000 + }); + + it("caches getAllTestEvents and invalidates on new event", () => { + logTestEvent("timer", 1100, timerData("start", 0)); + const first = getAllTestEvents(); + const second = getAllTestEvents(); + expect(first).toBe(second); // same reference = cached + + logTestEvent("timer", 2100, timerData("end", 1)); + const third = getAllTestEvents(); + expect(third).not.toBe(first); // new reference = invalidated + }); + }); + + describe("logTestEvent keydown filtering", () => { + it("ignores keys not in keysToTrack", () => { + logTestEvent("keydown", 1010, keyDown("Backspace")); + expect(getAllTestEvents()).toHaveLength(0); + }); + + it("ignores duplicate keydown without keyup", () => { + logTestEvent("keydown", 1010, keyDown()); + logTestEvent("keydown", 1020, keyDown()); + expect(getAllTestEvents()).toHaveLength(1); + }); + + it("allows keydown after keyup", () => { + logTestEvent("keydown", 1010, keyDown()); + logTestEvent("keyup", 1020, keyUp()); + logTestEvent("keydown", 1030, keyDown()); + expect(getAllTestEvents()).toHaveLength(3); + }); + }); + + describe("logTestEvent keyup filtering", () => { + it("ignores keyup for untracked keys", () => { + logTestEvent("keyup", 1010, keyUp("Backspace")); + expect(getAllTestEvents()).toHaveLength(0); + }); + + it("ignores keyup without prior keydown", () => { + logTestEvent("keyup", 1010, keyUp()); + expect(getAllTestEvents()).toHaveLength(0); + }); + }); + + describe("NoCode handling", () => { + it("tracks multiple simultaneous NoCode keydowns", () => { + logTestEvent("keydown", 1010, keyDown("NoCode")); + logTestEvent("keydown", 1020, keyDown("NoCode")); + + const events = getAllTestEvents(); + expect(events).toHaveLength(2); + }); + + it("tracks NoCode keyup after keydown", () => { + logTestEvent("keydown", 1010, keyDown("NoCode")); + logTestEvent("keyup", 1020, keyUp("NoCode")); + + const events = getAllTestEvents(); + expect(events).toHaveLength(2); + expect(events[0]!.type).toBe("keydown"); + expect(events[1]!.type).toBe("keyup"); + }); + + it("stores indexed code on keydown events", () => { + logTestEvent("keydown", 1010, keyDown("NoCode")); + logTestEvent("keydown", 1020, keyDown("NoCode")); + + const events = getAllTestEvents() as KeydownEvent[]; + expect(events[0]!.data.code).toBe("NoCode0"); + expect(events[1]!.data.code).toBe("NoCode1"); + }); + + it("stores matching indexed code on keyup events", () => { + logTestEvent("keydown", 1010, keyDown("NoCode")); + logTestEvent("keydown", 1020, keyDown("NoCode")); + logTestEvent("keyup", 1030, keyUp("NoCode")); + logTestEvent("keyup", 1040, keyUp("NoCode")); + + const events = getAllTestEvents(); + // keyups are LIFO — second keydown (NoCode1) is released first + expect((events[2] as KeyupEvent).data.code).toBe("NoCode1"); + expect((events[3] as KeyupEvent).data.code).toBe("NoCode0"); + }); + + it("ignores NoCode keyup when no matching keydown exists", () => { + logTestEvent("keyup", 1010, keyUp("NoCode")); + + expect(getAllTestEvents()).toHaveLength(0); + }); + + it("stray NoCode keyup does not corrupt noCodeIndex", () => { + // stray keyup with no matching keydown + logTestEvent("keyup", 1010, keyUp("NoCode")); + + // subsequent keydown/keyup should still work correctly + logTestEvent("keydown", 1020, keyDown("NoCode")); + logTestEvent("keyup", 1030, keyUp("NoCode")); + + const events = getAllTestEvents(); + expect(events).toHaveLength(2); + expect((events[0] as KeydownEvent).data.code).toBe("NoCode0"); + expect((events[1] as KeyupEvent).data.code).toBe("NoCode0"); + }); + + it("accepts already-indexed NoCode keyup", () => { + logTestEvent("keydown", 1010, keyDown("NoCode")); + logTestEvent("keydown", 1020, keyDown("NoCode")); + + // simulate forceReleaseAllKeys passing indexed codes directly + logTestEvent("keyup", 1030, { + code: "NoCode0", + ctrl: false, + shift: false, + alt: false, + meta: false, + } as KeyupEventData); + logTestEvent("keyup", 1040, { + code: "NoCode1", + ctrl: false, + shift: false, + alt: false, + meta: false, + } as KeyupEventData); + + const events = getAllTestEvents(); + expect(events).toHaveLength(4); + const keyups = events.filter((e) => e.type === "keyup"); + expect(keyups).toHaveLength(2); + expect((keyups[0] as KeyupEvent).data.code).toBe("NoCode0"); + expect((keyups[1] as KeyupEvent).data.code).toBe("NoCode1"); + }); + + it("rejects indexed NoCode keyup with no matching keydown", () => { + logTestEvent("keyup", 1010, { + code: "NoCode0", + ctrl: false, + shift: false, + alt: false, + meta: false, + } as KeyupEventData); + + expect(getAllTestEvents()).toHaveLength(0); + }); + }); + + describe("getInputEvents", () => { + it("returns only input events", () => { + logTestEvent("keydown", 1010, keyDown()); + logTestEvent("input", 1020, inputData()); + logTestEvent("timer", 1030, timerData("start", 0)); + logTestEvent("input", 1040, inputData({ charIndex: 1 })); + + const inputs = getInputEvents(); + expect(inputs).toHaveLength(2); + expect(inputs.every((e) => e.type === "input")).toBe(true); + }); + }); + + describe("getInputEventsPerWord", () => { + it("groups input events by wordIndex", () => { + logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); + logTestEvent("input", 1020, inputData({ wordIndex: 0, charIndex: 1 })); + logTestEvent("input", 1030, inputData({ wordIndex: 1, charIndex: 0 })); + + const perWord = getInputEventsPerWord(); + expect(perWord.get(0)).toHaveLength(2); + expect(perWord.get(1)).toHaveLength(1); + }); + + it("attributes deleteContentBackward at charIndex 0 to previous word", () => { + logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); + logTestEvent("input", 1020, { + charIndex: 0, + wordIndex: 1, + inputType: "deleteContentBackward", + } as InputEventData); + + const perWord = getInputEventsPerWord(); + expect(perWord.get(0)).toHaveLength(2); + expect(perWord.has(1)).toBe(false); + }); + + it("attributes deleteWordBackward at charIndex 0 to previous word", () => { + logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); + logTestEvent("input", 1020, { + charIndex: 0, + wordIndex: 1, + inputType: "deleteWordBackward", + } as InputEventData); + + const perWord = getInputEventsPerWord(); + expect(perWord.get(0)).toHaveLength(2); + expect(perWord.has(1)).toBe(false); + }); + + it("does not shift delete at charIndex 0 if wordIndex is 0", () => { + logTestEvent("input", 1010, { + charIndex: 0, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + + const perWord = getInputEventsPerWord(); + expect(perWord.get(0)).toHaveLength(1); + }); + + it("respects testMsLimit", () => { + logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); + logTestEvent("input", 1100, inputData({ wordIndex: 0, charIndex: 1 })); + + const perWord = getInputEventsPerWord(undefined, 50); + expect(perWord.get(0)).toHaveLength(1); + }); + + it("respects startMs", () => { + logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); + logTestEvent("input", 1100, inputData({ wordIndex: 0, charIndex: 1 })); + + const perWord = getInputEventsPerWord(50); + expect(perWord.get(0)).toHaveLength(1); + expect(perWord.get(0)![0]!.data.charIndex).toBe(1); + }); + }); + + describe("cleanupData", () => { + describe("pre-start filtering", () => { + it("removes all pre-start keydowns except the last", () => { + logTestEvent("keydown", 900, keyDown("KeyA")); + logTestEvent("keyup", 910, keyUp("KeyA")); + logTestEvent("keydown", 950, keyDown("KeyS")); + logTestEvent("timer", 1000, timerData("start", 0)); + logTestEvent("keyup", 1100, keyUp("KeyS")); + + cleanupData(); + const events = getAllTestEvents(); + const keydowns = events.filter((e) => e.type === "keydown"); + expect(keydowns).toHaveLength(1); + expect((keydowns[0] as KeydownEvent).data.code).toBe("KeyS"); + }); + + it("removes all pre-start keyups", () => { + logTestEvent("keydown", 900, keyDown("KeyA")); + logTestEvent("keyup", 910, keyUp("KeyA")); + logTestEvent("timer", 1000, timerData("start", 0)); + + cleanupData(); + const events = getAllTestEvents(); + const keyups = events.filter((e) => e.type === "keyup"); + expect(keyups).toHaveLength(0); + }); + + it("keeps pre-start non-key events (timer, input)", () => { + logTestEvent("input", 900, inputData()); + logTestEvent("keydown", 950, keyDown("KeyA")); + logTestEvent("timer", 1000, timerData("start", 0)); + + cleanupData(); + const events = getAllTestEvents(); + expect(events.filter((e) => e.type === "input")).toHaveLength(1); + }); + + it("does nothing when no timer start exists", () => { + logTestEvent("keydown", 1000, keyDown("KeyA")); + logTestEvent("keyup", 1050, keyUp("KeyA")); + logTestEvent("keydown", 1100, keyDown("KeyS")); + logTestEvent("keyup", 1150, keyUp("KeyS")); + + cleanupData(); + expect(getAllTestEvents()).toHaveLength(4); + }); + + it("removes all pre-start keydowns when there are none", () => { + logTestEvent("timer", 1000, timerData("start", 0)); + logTestEvent("keydown", 1100, keyDown("KeyA")); + + cleanupData(); + const events = getAllTestEvents(); + expect(events.filter((e) => e.type === "keydown")).toHaveLength(1); + }); + }); + + describe("post-end filtering", () => { + it("removes input events after timer end", () => { + logTestEvent("timer", 1000, timerData("start", 0)); + logTestEvent("input", 1100, inputData()); + logTestEvent("timer", 2000, timerData("end", 1)); + logTestEvent("input", 2100, inputData({ charIndex: 1 })); + + cleanupData(); + const events = getAllTestEvents(); + const inputs = events.filter((e) => e.type === "input"); + expect(inputs).toHaveLength(1); + }); + + it("removes keydowns after timer end", () => { + logTestEvent("timer", 1000, timerData("start", 0)); + logTestEvent("keydown", 1100, keyDown("KeyA")); + logTestEvent("keyup", 1200, keyUp("KeyA")); + logTestEvent("timer", 2000, timerData("end", 1)); + logTestEvent("keydown", 2100, keyDown("KeyS")); + logTestEvent("keyup", 2200, keyUp("KeyS")); + + cleanupData(); + const events = getAllTestEvents(); + const keydowns = events.filter((e) => e.type === "keydown"); + expect(keydowns).toHaveLength(1); + expect((keydowns[0] as KeydownEvent).data.code).toBe("KeyA"); + }); + + it("removes keyups associated with post-end keydowns", () => { + logTestEvent("timer", 1000, timerData("start", 0)); + logTestEvent("timer", 2000, timerData("end", 1)); + logTestEvent("keydown", 2100, keyDown("KeyA")); + logTestEvent("keyup", 2200, keyUp("KeyA")); + + cleanupData(); + const events = getAllTestEvents(); + expect(events.filter((e) => e.type === "keyup")).toHaveLength(0); + }); + + it("keeps keyups for keydowns that started before timer end", () => { + logTestEvent("timer", 1000, timerData("start", 0)); + logTestEvent("keydown", 1900, keyDown("KeyA")); + logTestEvent("timer", 2000, timerData("end", 1)); + logTestEvent("keyup", 2100, keyUp("KeyA")); + + cleanupData(); + const events = getAllTestEvents(); + const keyups = events.filter((e) => e.type === "keyup"); + expect(keyups).toHaveLength(1); + expect((keyups[0] as KeyupEvent).data.code).toBe("KeyA"); + }); + + it("keeps timer end event itself", () => { + logTestEvent("timer", 1000, timerData("start", 0)); + logTestEvent("timer", 2000, timerData("end", 1)); + + cleanupData(); + const events = getAllTestEvents(); + expect(events).toHaveLength(2); + }); + + it("does nothing when no timer end exists", () => { + logTestEvent("timer", 1000, timerData("start", 0)); + logTestEvent("keydown", 1100, keyDown("KeyA")); + logTestEvent("input", 1200, inputData()); + + cleanupData(); + expect(getAllTestEvents()).toHaveLength(3); + }); + }); + + describe("source array sync", () => { + it("cleanup persists after cache invalidation", () => { + logTestEvent("keydown", 900, keyDown("KeyA")); + logTestEvent("keyup", 910, keyUp("KeyA")); + logTestEvent("keydown", 950, keyDown("KeyS")); + logTestEvent("timer", 1000, timerData("start", 0)); + logTestEvent("keyup", 1100, keyUp("KeyS")); + logTestEvent("timer", 2000, timerData("end", 1)); + logTestEvent("input", 2100, inputData({ charIndex: 1 })); + logTestEvent("keydown", 2200, keyDown("KeyD")); + logTestEvent("keyup", 2300, keyUp("KeyD")); + + cleanupData(); + + // simulate cache invalidation + rebuild by logging a new event + logTestEvent("timer", 2500, timerData("step", 2)); + const events = getAllTestEvents(); + + // pre-start KeyA keydown/keyup should still be gone + const keydowns = events.filter((e) => e.type === "keydown"); + expect(keydowns).toHaveLength(1); + expect((keydowns[0] as KeydownEvent).data.code).toBe("KeyS"); + + // post-end input and KeyD keydown/keyup should still be gone + const inputs = events.filter((e) => e.type === "input"); + expect(inputs).toHaveLength(0); + expect( + events.filter( + (e) => + e.type === "keydown" && + (e.data as KeydownEventData).code === "KeyD", + ), + ).toHaveLength(0); + }); + }); + + describe("combined pre-start and post-end", () => { + it("filters both pre-start and post-end events", () => { + logTestEvent("keydown", 900, keyDown("KeyA")); + logTestEvent("keyup", 910, keyUp("KeyA")); + logTestEvent("keydown", 950, keyDown("KeyS")); + logTestEvent("timer", 1000, timerData("start", 0)); + logTestEvent("input", 1100, inputData()); + logTestEvent("keyup", 1200, keyUp("KeyS")); + logTestEvent("timer", 2000, timerData("end", 1)); + logTestEvent("input", 2100, inputData({ charIndex: 1 })); + logTestEvent("keydown", 2200, keyDown("KeyD")); + logTestEvent("keyup", 2300, keyUp("KeyD")); + + cleanupData(); + const events = getAllTestEvents(); + + // pre-start: only last keydown (KeyS) kept, keyup removed + // post-end: input, keydown (KeyD), keyup (KeyD) removed + const keydowns = events.filter((e) => e.type === "keydown"); + expect(keydowns).toHaveLength(1); + expect((keydowns[0] as KeydownEvent).data.code).toBe("KeyS"); + + const inputs = events.filter((e) => e.type === "input"); + expect(inputs).toHaveLength(1); + + const keyups = events.filter((e) => e.type === "keyup"); + expect(keyups).toHaveLength(1); + expect((keyups[0] as KeyupEvent).data.code).toBe("KeyS"); + }); + }); + }); + + describe("resetTestEvents", () => { + it("clears all events", () => { + logTestEvent("keydown", 1010, keyDown()); + logTestEvent("input", 1020, inputData()); + logTestEvent("timer", 1030, timerData("start", 0)); + + resetTestEvents(); + expect(getAllTestEvents()).toEqual([]); + }); + }); +}); diff --git a/frontend/__tests__/test/events/helpers.spec.ts b/frontend/__tests__/test/events/helpers.spec.ts new file mode 100644 index 000000000000..c79ec075c45d --- /dev/null +++ b/frontend/__tests__/test/events/helpers.spec.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const mockConfig = vi.hoisted(() => ({ funbox: "" })); +vi.mock("../../../src/ts/config/store", () => ({ + Config: mockConfig, +})); + +import { + getSimulatedInput, + getTestEventCode, +} from "../../../src/ts/test/events/helpers"; +import type { InputEvent } from "../../../src/ts/test/events/types"; +import type { InsertInputType } from "../../../src/ts/input/helpers/input-type"; + +let nextMs = 0; +let charIndex = 0; +let wordIndex = 0; + +function insert( + chars: string, + inputType: InsertInputType = "insertText", + overrides: Partial<{ inputStopped: boolean }> = {}, +): InputEvent[] { + return [...chars].map((char) => { + nextMs += 10; + const event: InputEvent = { + type: "input", + ms: nextMs, + testMs: nextMs, + data: { + charIndex, + wordIndex, + inputType, + data: char, + correct: true, + isCompositionEnding: false, + inputStopped: false, + ...overrides, + }, + }; + if (char !== " ") { + charIndex++; + } + if (char === " ") { + wordIndex++; + charIndex = 0; + } + return event; + }); +} + +function deleteBackward(count = 1): InputEvent[] { + return Array.from({ length: count }, () => { + nextMs += 10; + const event: InputEvent = { + type: "input", + ms: nextMs, + testMs: nextMs, + data: { + charIndex, + wordIndex, + inputType: "deleteContentBackward", + }, + }; + if (charIndex > 0) charIndex--; + return event; + }); +} + +function deleteWordBackward(): InputEvent { + nextMs += 10; + charIndex = 0; + const event = { + type: "input", + ms: nextMs, + testMs: nextMs, + data: { + charIndex, + wordIndex, + inputType: "deleteWordBackward", + }, + } as const; + if (wordIndex > 0) wordIndex--; + + return event; +} + +function reset(): void { + nextMs = 0; + charIndex = 0; + wordIndex = 0; +} + +describe("getSimulatedInput", () => { + beforeEach(() => { + reset(); + }); + + it("builds string from insertText events", () => { + expect(getSimulatedInput([...insert("hello")])).toBe("hello"); + }); + + it("builds string from insertText events with trailing space", () => { + expect(getSimulatedInput([...insert("hello ")])).toBe("hello "); + }); + + it("handles deleteContentBackward", () => { + expect(getSimulatedInput([...insert("abc"), ...deleteBackward()])).toBe( + "ab", + ); + }); + + it("handles deleteContentBackward after space", () => { + expect(getSimulatedInput([...insert("abc "), ...deleteBackward()])).toBe( + "abc", + ); + }); + + it("handles multiple deletes", () => { + expect(getSimulatedInput([...insert("ab"), ...deleteBackward(2)])).toBe(""); + }); + + it("handles multiple deletes after space", () => { + expect(getSimulatedInput([...insert("ab "), ...deleteBackward(2)])).toBe( + "a", + ); + }); + + it("handles deleteWordBackward", () => { + expect(getSimulatedInput([...insert("hello"), deleteWordBackward()])).toBe( + "", + ); + }); + + it("handles deleteWordBackward after space", () => { + expect(getSimulatedInput([...insert("hello "), deleteWordBackward()])).toBe( + "", + ); + }); + + it("returns empty string for no events", () => { + expect(getSimulatedInput([])).toBe(""); + }); + + it("handles deleteContentBackward on empty string", () => { + const events = [...deleteBackward()]; + expect(getSimulatedInput(events)).toBe(""); + }); + + it("skips inputStopped events", () => { + expect( + getSimulatedInput([ + ...insert("he"), + ...insert("x", "insertText", { inputStopped: true }), + ...insert("llo"), + ]), + ).toBe("hello"); + }); + + // it("handles insertCompositionText events", () => { + // const events = [ + // ...insert("k", "insertCompositionText"), + // ...insert("ka", "insertCompositionText"), + // ]; + // expect(getSimulatedInput(events)).toBe("ka"); + // }); + + // it("handles composition followed by regular text", () => { + // const events = [ + // ...insert("k", "insertCompositionText"), + // ...insert("ka", "insertCompositionText"), + // ...insert("b"), + // ]; + // expect(getSimulatedInput(events)).toBe("kab"); + // }); +}); + +function kbd(code: string, key?: string): KeyboardEvent { + return { code, key: key ?? "" } as KeyboardEvent; +} + +describe("getTestEventCode", () => { + beforeEach(() => { + mockConfig.funbox = ""; + }); + + it("returns the event code as-is for normal keys", () => { + expect(getTestEventCode(kbd("KeyA"))).toBe("KeyA"); + expect(getTestEventCode(kbd("Space"))).toBe("Space"); + expect(getTestEventCode(kbd("Digit1"))).toBe("Digit1"); + }); + + it("returns NoCode when code is empty string", () => { + expect(getTestEventCode(kbd(""))).toBe("NoCode"); + }); + + it("returns NoCode when key is Unidentified even with a valid code", () => { + expect(getTestEventCode(kbd("Semicolon", "Unidentified"))).toBe("NoCode"); + }); + + it("returns NoCode when key is Unidentified", () => { + expect(getTestEventCode(kbd("KeyA", "Unidentified"))).toBe("NoCode"); + }); + + it("returns Space for NumpadEnter when 58008 funbox is active", () => { + mockConfig.funbox = "58008"; + expect(getTestEventCode(kbd("NumpadEnter"))).toBe("Space"); + }); + + it("does not remap NumpadEnter without 58008 funbox", () => { + expect(getTestEventCode(kbd("NumpadEnter"))).toBe("NumpadEnter"); + }); + + it("returns NoCode for arrow keys when arrows funbox is active", () => { + mockConfig.funbox = "arrows"; + expect(getTestEventCode(kbd("ArrowUp"))).toBe("NoCode"); + expect(getTestEventCode(kbd("ArrowDown"))).toBe("NoCode"); + expect(getTestEventCode(kbd("ArrowLeft"))).toBe("NoCode"); + expect(getTestEventCode(kbd("ArrowRight"))).toBe("NoCode"); + }); + + it("does not remap arrow keys without arrows funbox", () => { + expect(getTestEventCode(kbd("ArrowUp"))).toBe("ArrowUp"); + }); + + it("handles 58008 funbox combined with other funboxes", () => { + mockConfig.funbox = "other#58008"; + expect(getTestEventCode(kbd("NumpadEnter"))).toBe("Space"); + }); + + it("handles arrows funbox combined with other funboxes", () => { + mockConfig.funbox = "arrows#other"; + expect(getTestEventCode(kbd("ArrowLeft"))).toBe("NoCode"); + }); +}); diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts new file mode 100644 index 000000000000..3c7d03a55a32 --- /dev/null +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -0,0 +1,749 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../../../src/ts/test/test-stats", () => ({ + start: 1000, +})); + +vi.mock("../../../src/ts/test/test-state", () => ({ + activeWordIndex: 0, + bailedOut: false, + resultCalculating: false, +})); + +vi.mock("../../../src/ts/config/store", () => ({ + Config: { mode: "words", funbox: "" }, +})); + +vi.mock("../../../src/ts/test/test-words", () => { + const list: string[] = []; + return { + words: { + list, + getText(i?: number) { + if (i === undefined) return list; + return list[i]; + }, + getCurrentText() { + return list[list.length - 1] ?? ""; + }, + }, + }; +}); + +vi.mock("../../../src/ts/test/custom-text", () => ({ + getLimit: () => ({ mode: "words", value: 0 }), +})); + +import { + logTestEvent, + resetTestEvents, + getAllTestEvents, + __testing, +} from "../../../src/ts/test/events/data"; +import { + getStartToFirstKeypressMs, + getLastKeypressToEndMs, + getRawPerSecond, + getTestDurationMs, + getAccuracy, + getKeypressSpacing, + getKeypressOverlap, + getErrorCountHistory, + getAfkDuration, + getKeypressDurations, + getKeypressesPerSecond, + getChars, + getWpmHistory, + forceReleaseAllKeys, + __testing as statsTesting, +} from "../../../src/ts/test/events/stats"; +import type { + InputEventData, + KeydownEventData, + KeyupEventData, + TimerEventData, +} from "../../../src/ts/test/events/types"; +import { Config } from "../../../src/ts/config/store"; +import { Keycode } from "../../../src/ts/constants/keys"; +import * as TestState from "../../../src/ts/test/test-state"; +import { words as TestWords } from "../../../src/ts/test/test-words"; + +function keyDown(code: Keycode = "KeyA"): KeydownEventData { + return { code, ctrl: false, shift: false, alt: false, meta: false }; +} + +function keyUp(code: Keycode = "KeyA"): KeyupEventData { + return { + code, + ctrl: false, + shift: false, + alt: false, + meta: false, + }; +} + +function input( + overrides: Partial<{ + charIndex: number; + wordIndex: number; + data: string; + correct: boolean; + inputType: string; + isCompositionEnding: boolean; + inputStopped: boolean; + }> = {}, +): InputEventData { + return { + charIndex: 0, + wordIndex: 0, + inputType: "insertText", + data: "a", + correct: true, + isCompositionEnding: false, + inputStopped: false, + ...overrides, + } as InputEventData; +} + +function timer( + event: "start" | "step" | "end", + timerVal: number, +): TimerEventData { + if (event === "step") { + return { event, timer: timerVal, drift: 0 }; + } + return { event, timer: timerVal }; +} + +// Helper: sets up a basic test with timer start, steps at 1s intervals, +// input events, and timer end +function setupBasicTest(): void { + // start=0, step@1s, step@2s, step@3s, end@3s + logTestEvent("timer", 1000, timer("start", 0)); + // 3 inputs in first second + logTestEvent("input", 1200, input()); + logTestEvent("input", 1400, input({ charIndex: 1 })); + logTestEvent("input", 1600, input({ charIndex: 2 })); + logTestEvent("timer", 2000, timer("step", 1)); + // 2 inputs in second second + logTestEvent("input", 2200, input({ charIndex: 3 })); + logTestEvent("input", 2400, input({ charIndex: 4 })); + logTestEvent("timer", 3000, timer("step", 2)); + // 1 input in third second + logTestEvent("input", 3200, input({ charIndex: 5 })); + logTestEvent("timer", 4000, timer("step", 3)); + logTestEvent("timer", 4000, timer("end", 3)); +} + +describe("stats.ts", () => { + beforeEach(() => { + resetTestEvents(); + __testing.resetPressedKeys(); + (Config as { mode: string }).mode = "words"; + (TestState as { activeWordIndex: number }).activeWordIndex = 0; + TestWords.list.length = 0; + }); + + describe("getTimerBoundaries", () => { + it("returns step boundaries and end", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 3000, timer("step", 2)); + logTestEvent("timer", 4000, timer("step", 3)); + logTestEvent("timer", 4000, timer("end", 3)); + + const events = getAllTestEvents(); + // end testMs=3000, last step testMs=3000 — gap is 0 < 500, end skipped + expect(statsTesting.getTimerBoundaries(events)).toEqual([ + 1000, 2000, 3000, + ]); + }); + + it("includes end as boundary when far enough from last step", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 3000, timer("end", 2)); + + const events = getAllTestEvents(); + // end at testMs 2000, last step at testMs 1000 — gap is 1000 >= 500 + expect(statsTesting.getTimerBoundaries(events)).toEqual([1000, 2000]); + }); + + it("skips end when too close to last step", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 2400, timer("end", 1)); + + const events = getAllTestEvents(); + // end at testMs 1400, last step at testMs 1000 — gap is 400 < 500 + expect(statsTesting.getTimerBoundaries(events)).toEqual([1000]); + }); + + it("excludes short trailing interval (<500ms) for non-round test duration", () => { + // 1.35s test: step at 1s, end at 1.35s — remainder 350ms < 500 + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 2350, timer("end", 1)); + + const events = getAllTestEvents(); + // end testMs=1350, last step testMs=1000 — gap is 350 < 500, end skipped + expect(statsTesting.getTimerBoundaries(events)).toEqual([1000]); + }); + + it("excludes short trailing interval (<500ms) for sub one second test duration", () => { + // 1.35s test: step at 1s, end at 1.35s — remainder 350ms < 500 + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 1350, timer("end", 0)); + + const events = getAllTestEvents(); + // end testMs=1350, last step testMs=1000 — gap is 350 < 500, end skipped + expect(statsTesting.getTimerBoundaries(events)).toEqual([]); + }); + + it("returns empty when no timer events", () => { + logTestEvent("keydown", 1000, keyDown()); + + const events = getAllTestEvents(); + expect(statsTesting.getTimerBoundaries(events)).toEqual([]); + }); + + it("adjusts end in zen mode by removing trailing afk", () => { + (Config as { mode: string }).mode = "zen"; + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1500, keyDown()); + logTestEvent("keyup", 1600, keyUp()); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 3000, timer("step", 2)); + // last keypress at testMs 500, end at testMs 4000 → lkte = 3500 + logTestEvent("timer", 5000, timer("end", 4)); + + const events = getAllTestEvents(); + const boundaries = statsTesting.getTimerBoundaries(events); + // adjusted end = 4000 - 3500 = 500, steps at 1000 and 2000 are past it + expect(boundaries).toEqual([500]); + }); + }); + + describe("getStartToFirstKeypressMs", () => { + it("returns time from start to first keydown", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1150, keyDown()); + + expect(getStartToFirstKeypressMs()).toBe(150); + }); + + it("returns 0 if keydown comes before start", () => { + logTestEvent("keydown", 900, keyDown()); + logTestEvent("timer", 1000, timer("start", 0)); + + expect(getStartToFirstKeypressMs()).toBe(0); + }); + + it("returns 0 in zen mode", () => { + (Config as { mode: string }).mode = "zen"; + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1150, keyDown()); + + expect(getStartToFirstKeypressMs()).toBe(0); + }); + + it("returns 0 if no events", () => { + expect(getStartToFirstKeypressMs()).toBe(0); + }); + }); + + describe("getLastKeypressToEndMs", () => { + it("returns time from last keydown to end", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1500, keyDown()); + logTestEvent("keyup", 1600, keyUp()); + logTestEvent("keydown", 1800, keyDown()); + logTestEvent("timer", 2000, timer("end", 1)); + + expect(getLastKeypressToEndMs()).toBe(200); + }); + + it("returns 0 in zen mode", () => { + (Config as { mode: string }).mode = "zen"; + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1500, keyDown()); + logTestEvent("timer", 2000, timer("end", 1)); + + expect(getLastKeypressToEndMs()).toBe(0); + }); + }); + + describe("getTestDurationMs", () => { + it("returns end testMs", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1500, keyDown()); + logTestEvent("timer", 4000, timer("end", 3)); + + expect(getTestDurationMs()).toBe(3000); + }); + + it("returns 0 if no end event", () => { + logTestEvent("timer", 1000, timer("start", 0)); + expect(getTestDurationMs()).toBe(0); + }); + }); + + describe("getRawPerSecond", () => { + it("converts keypresses to WPM using real interval duration", () => { + setupBasicTest(); + + const raw = getRawPerSecond(); + // 3 keypresses in 1s = (3/5)*60 = 36 WPM + expect(raw[0]).toBe(36); + // 2 keypresses in 1s = (2/5)*60 = 24 WPM + expect(raw[1]).toBe(24); + // 1 keypress in 1s = (1/5)*60 = 12 WPM + expect(raw[2]).toBe(12); + }); + + it("ignores non-insertText events", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("input", 1200, input()); + logTestEvent("input", 1400, { + charIndex: 1, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 2000, timer("end", 1)); + + const raw = getRawPerSecond(); + expect(raw).toEqual([12]); // 1 keypress in 1s + }); + }); + + describe("getErrorCountHistory", () => { + it("counts incorrect insertText events per interval", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("input", 1200, input({ correct: false })); + logTestEvent("input", 1400, input({ charIndex: 1 })); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("input", 2200, input({ charIndex: 2, correct: false })); + logTestEvent("input", 2400, input({ charIndex: 3, correct: false })); + logTestEvent("timer", 3000, timer("step", 2)); + logTestEvent("timer", 3000, timer("end", 2)); + + const errors = getErrorCountHistory(); + expect(errors).toEqual([1, 2]); + }); + + it("returns zeros when all correct", () => { + setupBasicTest(); + const errors = getErrorCountHistory(); + expect(errors).toEqual([0, 0, 0]); + }); + }); + + describe("getAfkDuration", () => { + it("counts intervals with no keydown events", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1200, keyDown()); + logTestEvent("keyup", 1300, keyUp()); + logTestEvent("timer", 2000, timer("step", 1)); + // no keydowns in second interval + logTestEvent("timer", 3000, timer("step", 2)); + logTestEvent("keydown", 3200, keyDown()); + logTestEvent("keyup", 3300, keyUp()); + logTestEvent("timer", 4000, timer("step", 3)); + logTestEvent("timer", 4000, timer("end", 3)); + + expect(getAfkDuration()).toBe(1); + }); + + it("returns 0 when all intervals have keydowns", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1200, keyDown()); + logTestEvent("keyup", 1300, keyUp()); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("keydown", 2200, keyDown()); + logTestEvent("keyup", 2300, keyUp()); + logTestEvent("timer", 3000, timer("step", 2)); + logTestEvent("timer", 3000, timer("end", 2)); + + expect(getAfkDuration()).toBe(0); + }); + }); + + describe("getAccuracy", () => { + it("calculates correct/incorrect/percentage", () => { + logTestEvent("input", 1100, input()); + logTestEvent("input", 1200, input({ charIndex: 1 })); + logTestEvent("input", 1300, input({ charIndex: 2, correct: false })); + + const acc = getAccuracy(); + expect(acc.correct).toBe(2); + expect(acc.incorrect).toBe(1); + expect(acc.percentage).toBeCloseTo(66.67, 1); + }); + + it("returns 0% for no events", () => { + const acc = getAccuracy(); + expect(acc.percentage).toBe(0); + }); + + it("ignores delete events", () => { + logTestEvent("input", 1100, input()); + logTestEvent("input", 1200, { + charIndex: 0, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + + const acc = getAccuracy(); + expect(acc.correct).toBe(1); + expect(acc.incorrect).toBe(0); + }); + + it("counts inputStopped events in accuracy", () => { + logTestEvent("input", 1100, input()); + logTestEvent( + "input", + 1200, + input({ charIndex: 1, correct: false, inputStopped: true }), + ); + + const acc = getAccuracy(); + expect(acc.correct).toBe(1); + expect(acc.incorrect).toBe(1); + expect(acc.percentage).toBe(50); + }); + }); + + describe("getKeypressSpacing", () => { + it("returns spacing between consecutive keydowns", () => { + logTestEvent("keydown", 1000, keyDown()); + logTestEvent("keyup", 1050, keyUp()); + logTestEvent("keydown", 1100, keyDown()); + logTestEvent("keyup", 1150, keyUp()); + logTestEvent("keydown", 1250, keyDown()); + logTestEvent("keyup", 1300, keyUp()); + + const spacings = getKeypressSpacing(); + expect(spacings).toEqual([100, 150]); + }); + + it("returns empty for single keydown", () => { + logTestEvent("keydown", 1000, keyDown()); + + expect(getKeypressSpacing()).toEqual([]); + }); + }); + + describe("getKeypressOverlap", () => { + it("measures time when multiple keys are held", () => { + logTestEvent("keydown", 1000, keyDown("KeyA")); + logTestEvent("keydown", 1050, keyDown("KeyS")); + // both held from 1050-1080 = 30ms overlap + logTestEvent("keyup", 1080, keyUp("KeyA")); + logTestEvent("keyup", 1100, keyUp("KeyS")); + + expect(getKeypressOverlap()).toBe(30); + }); + + it("returns 0 with no overlap", () => { + logTestEvent("keydown", 1000, keyDown("KeyA")); + logTestEvent("keyup", 1050, keyUp("KeyA")); + logTestEvent("keydown", 1100, keyDown("KeyS")); + logTestEvent("keyup", 1150, keyUp("KeyS")); + + expect(getKeypressOverlap()).toBe(0); + }); + }); + + describe("getKeypressDurations", () => { + it("measures hold duration for each key", () => { + logTestEvent("keydown", 1000, keyDown("KeyA")); + logTestEvent("keyup", 1080, keyUp("KeyA")); + logTestEvent("keydown", 1100, keyDown("KeyS")); + logTestEvent("keyup", 1200, keyUp("KeyS")); + + const durations = getKeypressDurations(); + expect(durations).toEqual([80, 100]); + }); + + it("returns 0 for keys without keyup", () => { + logTestEvent("keydown", 1000, keyDown()); + + const durations = getKeypressDurations(); + expect(durations).toEqual([0]); + }); + }); + + describe("getKeypressesPerSecond", () => { + it("counts insertText events per timer interval", () => { + setupBasicTest(); + + const kps = getKeypressesPerSecond(); + expect(kps).toEqual([3, 2, 1]); + }); + + it("ignores delete events", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("input", 1200, input()); + logTestEvent("input", 1400, { + charIndex: 1, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 2000, timer("end", 1)); + + expect(getKeypressesPerSecond()).toEqual([1]); + }); + + it("returns empty for no timer events", () => { + logTestEvent("input", 1200, input()); + expect(getKeypressesPerSecond()).toEqual([]); + }); + }); + + describe("getChars", () => { + it("counts all correct for a perfectly typed word", () => { + TestWords.list.push("hello"); + (TestState as { activeWordIndex: number }).activeWordIndex = 0; + + logTestEvent("timer", 1000, timer("start", 0)); + for (let i = 0; i < 5; i++) { + logTestEvent( + "input", + 1100 + i * 50, + input({ charIndex: i, wordIndex: 0, data: "hello"[i] as string }), + ); + } + + const chars = getChars(); + expect(chars.allCorrect).toBe(5); + expect(chars.correctWord).toBe(5); + expect(chars.incorrect).toBe(0); + expect(chars.extra).toBe(0); + expect(chars.missed).toBe(0); + }); + + it("counts incorrect chars", () => { + TestWords.list.push("ab"); + (TestState as { activeWordIndex: number }).activeWordIndex = 0; + + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "a" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "x", correct: false }), + ); + + const chars = getChars(); + expect(chars.allCorrect).toBe(1); + expect(chars.incorrect).toBe(1); + }); + + it("counts extra chars", () => { + TestWords.list.push("ab"); + (TestState as { activeWordIndex: number }).activeWordIndex = 0; + + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "a" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "b" }), + ); + logTestEvent( + "input", + 1200, + input({ charIndex: 2, wordIndex: 0, data: "c" }), + ); + + const chars = getChars(); + expect(chars.extra).toBe(1); + }); + + it("counts missed chars for completed non-last words", () => { + TestWords.list.push("hello", "world"); + (TestState as { activeWordIndex: number }).activeWordIndex = 1; + + logTestEvent("timer", 1000, timer("start", 0)); + // type "hel" then space (incomplete first word) + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "h" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "e" }), + ); + logTestEvent( + "input", + 1200, + input({ charIndex: 2, wordIndex: 0, data: "l" }), + ); + logTestEvent( + "input", + 1250, + input({ charIndex: 3, wordIndex: 0, data: " " }), + ); + // type "w" on second word + logTestEvent( + "input", + 1300, + input({ charIndex: 0, wordIndex: 1, data: "w" }), + ); + + const chars = getChars(); + // word 0: "hel " vs "hello " → 3 correct, 1 incorrect, 2 missed + // word 1: "w" vs "world" → 1 correct, 4 missed (words mode counts partial last word missed) + expect(chars.missed).toBe(6); + }); + }); + + describe("getWpmHistory", () => { + it("returns wpm at each timer boundary", () => { + TestWords.list.push("hello"); + (TestState as { activeWordIndex: number }).activeWordIndex = 0; + + logTestEvent("timer", 1000, timer("start", 0)); + // type "hello" in first second — 5 correct word chars + for (let i = 0; i < 5; i++) { + logTestEvent( + "input", + 1100 + i * 50, + input({ charIndex: i, wordIndex: 0, data: "hello"[i] as string }), + ); + } + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 2000, timer("end", 1)); + + const wpm = getWpmHistory(); + // 5 correct chars in 1s = (5/5)*60 = 60 WPM + expect(wpm).toEqual([60]); + }); + + it("returns cumulative wpm across boundaries", () => { + TestWords.list.push("ab", "cd"); + (TestState as { activeWordIndex: number }).activeWordIndex = 1; + + logTestEvent("timer", 1000, timer("start", 0)); + // type "ab " in first second — correct word + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "a" }), + ); + logTestEvent( + "input", + 1200, + input({ charIndex: 1, wordIndex: 0, data: "b" }), + ); + logTestEvent( + "input", + 1300, + input({ charIndex: 2, wordIndex: 0, data: " " }), + ); + logTestEvent("timer", 2000, timer("step", 1)); + // type "cd" in second second + logTestEvent( + "input", + 2100, + input({ charIndex: 0, wordIndex: 1, data: "c" }), + ); + logTestEvent( + "input", + 2200, + input({ charIndex: 1, wordIndex: 1, data: "d" }), + ); + logTestEvent("timer", 3000, timer("step", 2)); + logTestEvent("timer", 3000, timer("end", 2)); + + const wpm = getWpmHistory(); + expect(wpm.length).toBe(2); + // at 1s: "ab " fully correct = 3 correctWord chars → (3/5)*60 = 36 + expect(wpm[0]).toBe(36); + // at 2s: 3 + 2 ("cd") = 5 correctWord chars → (5/5)*60/2 = 30 + expect(wpm[1]).toBe(30); + }); + }); + + describe("forceReleaseAllKeys", () => { + it("creates synthetic keyup events for pressed keys", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1100, keyDown("KeyA")); + logTestEvent("keyup", 1180, keyUp("KeyA")); + // KeyS is still held + logTestEvent("keydown", 1200, keyDown("KeyS")); + + forceReleaseAllKeys(); + + const events = getAllTestEvents(); + const keyups = events.filter( + (e) => e.type === "keyup" && e.data.code === "KeyS", + ); + expect(keyups.length).toBe(1); + expect((keyups[0] as { data: { estimated?: true } }).data.estimated).toBe( + true, + ); + }); + + it("uses average duration for estimated keyup timing", () => { + logTestEvent("timer", 1000, timer("start", 0)); + // KeyA held for 80ms + logTestEvent("keydown", 1100, keyDown("KeyA")); + logTestEvent("keyup", 1180, keyUp("KeyA")); + // KeyS held for 120ms + logTestEvent("keydown", 1200, keyDown("KeyS")); + logTestEvent("keyup", 1320, keyUp("KeyS")); + // KeyD still held at 1400 + logTestEvent("keydown", 1400, keyDown("KeyD")); + + forceReleaseAllKeys(); + + const events = getAllTestEvents(); + const keyup = events.find( + (e) => e.type === "keyup" && e.data.code === "KeyD", + ); + // avg duration = (80+120)/2 = 100, so keyup at 1400+100 = 1500 + expect(keyup).toBeDefined(); + expect(keyup!.ms).toBe(1500); + }); + + it("uses default 80ms when no completed key durations exist", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1200, keyDown("KeyA")); + + forceReleaseAllKeys(); + + const events = getAllTestEvents(); + const keyup = events.find( + (e) => e.type === "keyup" && e.data.code === "KeyA", + ); + expect(keyup).toBeDefined(); + expect(keyup!.ms).toBe(1280); + }); + + it("does nothing when no keys are pressed", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("keydown", 1100, keyDown("KeyA")); + logTestEvent("keyup", 1180, keyUp("KeyA")); + + // const beforeCount = getAllTestEvents().length; + forceReleaseAllKeys(); + // cache invalidated, re-get + resetTestEvents(); + // no new events should have been added — but we can't easily check after reset + // so instead verify no error is thrown + }); + }); +}); diff --git a/frontend/__tests__/utils/strings.spec.ts b/frontend/__tests__/utils/strings.spec.ts index 8fa02f4c5e81..064dbe9856ee 100644 --- a/frontend/__tests__/utils/strings.spec.ts +++ b/frontend/__tests__/utils/strings.spec.ts @@ -587,4 +587,429 @@ describe("string utils", () => { }); }); }); + + describe("countChars", () => { + describe("it should count characters correctly", () => { + const testCases = [ + { + description: "correct, partial, not last", + input: { + inputWord: "hel", + targetWord: "hello ", + lastWord: false, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 3, + correctWord: 0, + incorrect: 0, + extra: 0, + missed: 3, + }, + }, + { + description: "correct, partial, last, shouldnt count", + input: { + inputWord: "hel", + targetWord: "hello ", + lastWord: true, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 3, + correctWord: 0, + incorrect: 0, + extra: 0, + missed: 3, + }, + }, + { + description: "correct, partial, last, should count", + input: { + inputWord: "hel", + targetWord: "hello ", + lastWord: true, + shouldLastPartialWordCount: true, + }, + expected: { + allCorrect: 3, + correctWord: 3, + incorrect: 0, + extra: 0, + missed: 0, + }, + }, + { + description: "correct", + input: { + inputWord: "hello ", + targetWord: "hello ", + lastWord: false, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 6, + correctWord: 6, + incorrect: 0, + extra: 0, + missed: 0, + }, + }, + { + description: "correct last", + input: { + inputWord: "hello ", + targetWord: "hello ", + lastWord: true, + shouldLastPartialWordCount: true, + }, + expected: { + allCorrect: 6, + correctWord: 6, + incorrect: 0, + extra: 0, + missed: 0, + }, + }, + { + description: "correct last no space", + input: { + inputWord: "hello", + targetWord: "hello ", + lastWord: true, + shouldLastPartialWordCount: true, + }, + expected: { + allCorrect: 5, + correctWord: 5, + incorrect: 0, + extra: 0, + missed: 0, + }, + }, + { + description: "correct with extra characters", + input: { + inputWord: "helloxxx ", + targetWord: "hello ", + lastWord: false, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 5, + correctWord: 0, + incorrect: 1, + extra: 3, + missed: 0, + }, + }, + { + description: + "correct, partial, not last, should count (should count last partial)", + input: { + inputWord: "hel", + targetWord: "hello ", + lastWord: false, + shouldLastPartialWordCount: true, + }, + expected: { + allCorrect: 3, + correctWord: 0, + incorrect: 0, + extra: 0, + missed: 3, + }, + }, + { + description: "early space", + input: { + inputWord: "hel ", + targetWord: "hello ", + lastWord: false, + shouldLastPartialWordCount: true, + }, + expected: { + allCorrect: 3, + correctWord: 0, + incorrect: 1, + extra: 0, + missed: 2, + }, + }, + { + description: "all incorrect, early space", + input: { + inputWord: "xxx ", + targetWord: "hello ", + lastWord: false, + shouldLastPartialWordCount: true, + }, + expected: { + allCorrect: 0, + correctWord: 0, + incorrect: 4, + extra: 0, + missed: 2, + }, + }, + { + description: "all incorrect, extra", + input: { + inputWord: "xxxxxx ", + targetWord: "hello ", + lastWord: false, + shouldLastPartialWordCount: true, + }, + expected: { + allCorrect: 0, + correctWord: 0, + incorrect: 6, + extra: 1, + missed: 0, + }, + }, + { + description: "some correct, extra", + input: { + inputWord: "xexlxx ", + targetWord: "hello ", + lastWord: false, + shouldLastPartialWordCount: true, + }, + expected: { + allCorrect: 2, + correctWord: 0, + incorrect: 4, + extra: 1, + missed: 0, + }, + }, + { + description: "some correct, early space", + input: { + inputWord: "xexl ", + targetWord: "hello ", + lastWord: false, + shouldLastPartialWordCount: true, + }, + expected: { + allCorrect: 2, + correctWord: 0, + incorrect: 3, + extra: 0, + missed: 1, + }, + }, + { + description: "incorrect, last word, quick end", + input: { + inputWord: "xello", + targetWord: "hello", + lastWord: true, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 4, + correctWord: 0, + incorrect: 1, + extra: 0, + missed: 0, + }, + }, + { + description: "incorrect, last word, noquick end", + input: { + inputWord: "xello ", + targetWord: "hello", + lastWord: true, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 4, + correctWord: 0, + incorrect: 1, + extra: 1, + missed: 0, + }, + }, + { + description: "correct space, incorrect word", + input: { + inputWord: "helol ", + targetWord: "hello ", + lastWord: true, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 3, + correctWord: 0, + incorrect: 3, + extra: 0, + missed: 0, + }, + }, + { + description: "single incorrect char", + input: { + inputWord: "hxllo ", + targetWord: "hello ", + lastWord: false, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 4, + correctWord: 0, + incorrect: 2, + extra: 0, + missed: 0, + }, + }, + { + description: "one extra char", + input: { + inputWord: "helloo ", + targetWord: "hello ", + lastWord: false, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 5, + correctWord: 0, + incorrect: 1, + extra: 1, + missed: 0, + }, + }, + { + description: "missed chars, no trailing space on target", + input: { + inputWord: "hel", + targetWord: "hello", + lastWord: false, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 3, + correctWord: 0, + incorrect: 0, + extra: 0, + missed: 2, + }, + }, + { + description: + "last partial match counts correctWord, no trailing space on target", + input: { + inputWord: "hel", + targetWord: "hello", + lastWord: true, + shouldLastPartialWordCount: true, + }, + expected: { + allCorrect: 3, + correctWord: 3, + incorrect: 0, + extra: 0, + missed: 0, + }, + }, + { + description: "last incorrect partial word doesn't count missed", + input: { + inputWord: "xxx", + targetWord: "hello", + lastWord: true, + shouldLastPartialWordCount: true, + }, + expected: { + allCorrect: 0, + correctWord: 0, + incorrect: 3, + extra: 0, + missed: 0, + }, + }, + { + description: "last partial no count, no trailing space on target", + input: { + inputWord: "hel", + targetWord: "hello", + lastWord: true, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 3, + correctWord: 0, + incorrect: 0, + extra: 0, + missed: 2, + }, + }, + { + description: "non-last word ignores shouldLastPartialWordCount", + input: { + inputWord: "hel", + targetWord: "hello", + lastWord: false, + shouldLastPartialWordCount: true, + }, + expected: { + allCorrect: 3, + correctWord: 0, + incorrect: 0, + extra: 0, + missed: 2, + }, + }, + { + description: "empty input counts all as missed", + input: { + inputWord: "", + targetWord: "hello", + lastWord: false, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 0, + correctWord: 0, + incorrect: 0, + extra: 0, + missed: 5, + }, + }, + { + description: "empty target counts all as extra", + input: { + inputWord: "hello", + targetWord: "", + lastWord: false, + shouldLastPartialWordCount: false, + }, + expected: { + allCorrect: 0, + correctWord: 0, + incorrect: 0, + extra: 5, + missed: 0, + }, + }, + ]; + + it.each(testCases)("$description", ({ input, expected }) => { + expect( + Strings.countChars( + input.inputWord, + input.targetWord, + input.lastWord, + input.shouldLastPartialWordCount, + ), + ).toEqual(expected); + }); + }); + + it("space counts as incorrect when word is wrong", () => { + const result = Strings.countChars("hell ", "hello ", false, false); + expect(result.incorrect).toBe(1); + }); + }); }); diff --git a/frontend/src/ts/constants/keys.ts b/frontend/src/ts/constants/keys.ts index 6875e53d770a..bee60a59f6e2 100644 --- a/frontend/src/ts/constants/keys.ts +++ b/frontend/src/ts/constants/keys.ts @@ -12,6 +12,7 @@ export type Keycode = | "Digit0" | "Minus" | "Equal" + | "Tab" | "KeyQ" | "KeyW" | "KeyE" diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index 0adf0b8ff06b..8d2a44340a47 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -7,11 +7,14 @@ import * as Replay from "../../test/replay"; import { Config } from "../../config/store"; import { goToPreviousWord } from "../helpers/word-navigation"; import { DeleteInputType } from "../helpers/input-type"; +import { logTestEvent } from "../../test/events/data"; +import { activeWordIndex } from "../../test/test-state"; -export function onDelete(inputType: DeleteInputType): void { +export function onDelete(inputType: DeleteInputType, now: number): void { const { realInputValue } = getInputElementValue(); const inputBeforeDelete = TestInput.input.current; + const activeWordIndexBeforeDelete = activeWordIndex; TestInput.input.syncWithInputElement(); @@ -44,5 +47,11 @@ export function onDelete(inputType: DeleteInputType): void { } } + logTestEvent("input", now, { + inputType: inputType, + wordIndex: activeWordIndexBeforeDelete, + charIndex: inputBeforeDelete.length, + }); + TestUI.afterTestDelete(); } diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index ff19d06333e9..6145641c5a49 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -37,6 +37,7 @@ import { isCharCorrect, shouldInsertSpaceCharacter, } from "../helpers/validation"; +import { logTestEvent } from "../../test/events/data"; const charOverrides = new Map([ ["…", "..."], @@ -225,6 +226,16 @@ export async function onInsertText(options: OnInsertTextParams): Promise { increasedWordIndex = result.increasedWordIndex; } + logTestEvent("input", now, { + inputType: "insertText", + data, + correct, + wordIndex, + charIndex: testInput.length, + isCompositionEnding: isCompositionEnding === true, + inputStopped: removeLastChar, + }); + /* Probably a good place to explain what the heck is going on with all these space related variables: - spaceOrNewLine: did the user input a space or a new line? diff --git a/frontend/src/ts/input/handlers/keydown.ts b/frontend/src/ts/input/handlers/keydown.ts index 569b65d14b02..a2beca801445 100644 --- a/frontend/src/ts/input/handlers/keydown.ts +++ b/frontend/src/ts/input/handlers/keydown.ts @@ -26,6 +26,8 @@ import { } from "../../test/funbox/list"; import { Keycode } from "../../constants/keys"; import { wordsHaveTab } from "../../states/test"; +import { logTestEvent } from "../../test/events/data"; +import { getTestEventCode } from "../../test/events/helpers"; export async function handleTab(e: KeyboardEvent, now: number): Promise { if (wordsHaveTab() && !e.shiftKey) { @@ -125,8 +127,23 @@ async function handleFunboxes( } export async function onKeydown(event: KeyboardEvent): Promise { + if (event.repeat) { + // just ignore all repeats + return; + } + const now = performance.now(); - TestInput.recordKeydownTime(now, event); + if (!TestState.resultCalculating) { + TestInput.recordKeydownTime(now, event); + } + + logTestEvent("keydown", now, { + code: getTestEventCode(event), + ctrl: event.ctrlKey, + shift: event.shiftKey, + alt: event.altKey, + meta: event.metaKey, + }); // allow arrows in arrows funbox const arrowsActive = Config.funbox.includes("arrows"); diff --git a/frontend/src/ts/input/handlers/keyup.ts b/frontend/src/ts/input/handlers/keyup.ts index 97c3321db8ee..2e04d12a7aba 100644 --- a/frontend/src/ts/input/handlers/keyup.ts +++ b/frontend/src/ts/input/handlers/keyup.ts @@ -1,10 +1,19 @@ import { Config } from "../../config/store"; import * as TestInput from "../../test/test-input"; import * as Monkey from "../../test/monkey"; +import { logTestEvent } from "../../test/events/data"; +import { getTestEventCode } from "../../test/events/helpers"; export async function onKeyup(event: KeyboardEvent): Promise { const now = performance.now(); TestInput.recordKeyupTime(now, event); + logTestEvent("keyup", now, { + code: getTestEventCode(event), + ctrl: event.ctrlKey, + shift: event.shiftKey, + alt: event.altKey, + meta: event.metaKey, + }); // allow arrows in arrows funbox const arrowsActive = Config.funbox.includes("arrows"); diff --git a/frontend/src/ts/input/listeners/composition.ts b/frontend/src/ts/input/listeners/composition.ts index 1747db68960c..cec96ead8fad 100644 --- a/frontend/src/ts/input/listeners/composition.ts +++ b/frontend/src/ts/input/listeners/composition.ts @@ -6,6 +6,7 @@ import * as TestInput from "../../test/test-input"; import { setLastInsertCompositionTextData } from "../state"; import * as CompositionDisplay from "../../elements/composition-display"; import { onInsertText } from "../handlers/insert-text"; +import { logTestEvent } from "../../test/events/data"; const inputEl = getInputElement(); @@ -15,16 +16,22 @@ inputEl.addEventListener("compositionstart", (event) => { data: event.data, }); + const now = performance.now(); + if (TestState.testRestarting || TestState.resultCalculating) return; CompositionState.setComposing(true); CompositionState.setData(""); setLastInsertCompositionTextData(""); if (!TestState.isActive) { - TestLogic.startTest(performance.now()); + TestLogic.startTest(now); } if (TestInput.input.current.length === 0) { - TestInput.setBurstStart(performance.now()); + TestInput.setBurstStart(now); } + + logTestEvent("composition", now, { + event: "start", + }); }); inputEl.addEventListener("compositionupdate", (event) => { @@ -36,6 +43,13 @@ inputEl.addEventListener("compositionupdate", (event) => { if (TestState.testRestarting || TestState.resultCalculating) return; CompositionState.setData(event.data); CompositionDisplay.update(event.data); + + const now = performance.now(); + + logTestEvent("composition", now, { + event: "update", + data: event.data, + }); }); inputEl.addEventListener("compositionend", async (event) => { @@ -56,4 +70,9 @@ inputEl.addEventListener("compositionend", async (event) => { isCompositionEnding: true, }); } + + logTestEvent("composition", now, { + event: "end", + data: event.data, + }); }); diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index c254b7bb62f1..7f697c2697d8 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -116,7 +116,7 @@ inputEl.addEventListener("input", async (event) => { inputType === "deleteWordBackward" || inputType === "deleteContentBackward" ) { - onDelete(inputType); + onDelete(inputType, now); } else if ( inputType === "insertCompositionText" || inputType === "insertFromComposition" diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts new file mode 100644 index 000000000000..ebda2b4c76ba --- /dev/null +++ b/frontend/src/ts/test/events/data.ts @@ -0,0 +1,355 @@ +import { + CompositionTestEvent, + CompositionTestEventData, + InputEvent, + InputEventData, + KeydownEvent, + KeydownEventData, + KeyupEvent, + KeyupEventData, + TestEvent, + TestEventData, + TestEventType, + TimerEvent, + TimerEventData, +} from "./types"; +import { keysToTrack } from "./helpers"; +import { start } from "../test-stats"; +import { Keycode } from "../../constants/keys"; +import { roundTo2 } from "@monkeytype/util/numbers"; +import { resultCalculating } from "../test-state"; + +let keydownEvents: KeydownEvent[] = []; +let keyupEvents: KeyupEvent[] = []; +let timerEvents: TimerEvent[] = []; +let inputEvents: InputEvent[] = []; +let compositionEvents: CompositionTestEvent[] = []; + +let cachedAllEvents: TestEvent[] | undefined; + +let noCodeIndex = 0; +let pressedKeys: Map< + Keycode | "NoCode" | `NoCode${number}`, + { + timestamp: number; + } +> = new Map(); + +export function logTestEvent( + type: TestEventType, + now: number, + eventData: TestEventData, +): void { + invalidateCache(); + + now = roundTo2(now); + + if (type === "keydown") { + const data = eventData as KeydownEventData; + const code = data.code as Keycode | "NoCode"; + + if (!keysToTrack.has(code)) { + return; + } + + if (pressedKeys.has(code)) { + //already pressed - ignore + return; + } + + if (resultCalculating) { + return; + } + + let key: Keycode | "NoCode" | `NoCode${number}` = code; + if (key === "NoCode") { + key = `NoCode${noCodeIndex}`; + noCodeIndex++; + } + + pressedKeys.set(key, { + timestamp: now, + }); + + keydownEvents.push({ + type, + ms: now, + testMs: 0, + data: { ...data, code: key }, + }); + } else if (type === "keyup") { + const data = eventData as KeyupEventData; + const code = data.code; + + let key: Keycode | "NoCode" | `NoCode${number}` = code; + + if (/^NoCode\d+$/.test(code)) { + // already indexed (e.g. from forceReleaseAllKeys) + } else { + if (!keysToTrack.has(code as Keycode | "NoCode")) { + return; + } + + if (code === "NoCode") { + key = `NoCode${noCodeIndex - 1}`; + if (!pressedKeys.has(key)) { + return; + } + noCodeIndex--; + } + } + + if (!pressedKeys.has(key)) { + //not pressed - ignore + return; + } + + pressedKeys.delete(key); + + keyupEvents.push({ + type, + ms: now, + testMs: 0, + data: { ...data, code: key }, + }); + } else if (type === "timer") { + timerEvents.push({ + type, + ms: now, + testMs: 0, + data: eventData as TimerEventData, + }); + } else if (type === "input") { + inputEvents.push({ + type, + ms: now, + testMs: 0, + data: eventData as InputEventData, + }); + } else if (type === "composition") { + compositionEvents.push({ + type, + ms: now, + testMs: 0, + data: eventData as CompositionTestEventData, + }); + } else { + throw new Error(`Unsupported event type: ${type}`); + } + console.debug(`Test events - logTestEvent - ${now}ms - ${type}`, eventData); +} + +function invalidateCache(): void { + cachedAllEvents = undefined; +} + +export function cleanupData(): void { + invalidateCache(); + getAllTestEvents(); + + if (cachedAllEvents === undefined) { + throw new Error( + "cachedAllEvents should not be undefined after getAllTestEvents", + ); + } + + //remove all pre-start keydown/keyup events except the last keydown + const timerStartIndex = cachedAllEvents.findIndex( + (e) => e.type === "timer" && e.data.event === "start", + ); + if (timerStartIndex !== -1) { + // find the last keydown before timer start + let lastPreStartKeydownIndex = -1; + for (let i = timerStartIndex - 1; i >= 0; i--) { + if (cachedAllEvents[i]?.type === "keydown") { + lastPreStartKeydownIndex = i; + break; + } + } + cachedAllEvents = cachedAllEvents.filter((e, index) => { + if (index >= timerStartIndex) return true; + if (e.type === "keydown") return index === lastPreStartKeydownIndex; + if (e.type === "keyup") return false; + return true; + }); + } + + //remove all input events after timer end + const timerEndIndex = cachedAllEvents.findIndex( + (e) => e.type === "timer" && e.data.event === "end", + ); + if (timerEndIndex !== -1) { + cachedAllEvents = cachedAllEvents.filter( + (e, index) => !(e.type === "input" && index > timerEndIndex), + ); + } + + //remove keydowns after timer end, and their associated keyups + if (timerEndIndex !== -1) { + const keydownsAfterTimerEnd = new Set( + cachedAllEvents + .filter((e, index) => e.type === "keydown" && index > timerEndIndex) + .map((e) => (e.data as KeydownEventData).code), + ); + cachedAllEvents = cachedAllEvents.filter((e, index) => { + if (index <= timerEndIndex) return true; + if (e.type === "keydown") return false; + if (e.type === "keyup") { + return !keydownsAfterTimerEnd.has(e.data.code); + } + return true; + }); + } + + // sync source arrays back from cleaned cache + keydownEvents = cachedAllEvents.filter( + (e): e is KeydownEvent => e.type === "keydown", + ); + keyupEvents = cachedAllEvents.filter( + (e): e is KeyupEvent => e.type === "keyup", + ); + timerEvents = cachedAllEvents.filter( + (e): e is TimerEvent => e.type === "timer", + ); + inputEvents = cachedAllEvents.filter( + (e): e is InputEvent => e.type === "input", + ); + compositionEvents = cachedAllEvents.filter( + (e): e is CompositionTestEvent => e.type === "composition", + ); +} + +export function getAllTestEvents(): TestEvent[] { + if (cachedAllEvents !== undefined) return cachedAllEvents; + + // cachedAllEvents = testData300; + // return cachedAllEvents; + cachedAllEvents = [ + ...keydownEvents, + ...keyupEvents, + ...timerEvents, + ...inputEvents, + ...compositionEvents, + ] + .sort( + (a, b) => + a.ms - b.ms || + (a.type === "timer" ? 1 : 0) - (b.type === "timer" ? 1 : 0), + ) + .map((event) => { + event.testMs = roundTo2(event.ms - start); + return event; + }); + + return cachedAllEvents; +} + +export function logEventsDataToTheConsole(): void { + console.debug( + getAllTestEvents().map((event) => { + const d = event.data; + let e = { + ...event, + ...event.data, + }; + //@ts-expect-error just for logging + delete e.data; + //@ts-expect-error just for logging + e = { + ...e, + ...d, + }; + return e; + }), + ); +} + +export function logEventsDataToTheConsoleTable(): void { + console.table( + getAllTestEvents().map((event) => { + const d = event.data; + let e = { + ...event, + ...event.data, + }; + //@ts-expect-error just for logging + delete e.data; + //@ts-expect-error just for logging + e = { + ...e, + ...d, + }; + return e; + }), + ); +} + +export function resetTestEvents(): void { + keydownEvents = []; + keyupEvents = []; + timerEvents = []; + inputEvents = []; + compositionEvents = []; + invalidateCache(); + pressedKeys = new Map(); + noCodeIndex = 0; +} + +export function getInputEvents(): InputEvent[] { + return getAllTestEvents().filter( + (event): event is InputEvent => event.type === "input", + ); +} + +export function getPressedKeys(): Map< + Keycode | "NoCode" | `NoCode${number}`, + { timestamp: number } +> { + return pressedKeys; +} + +export function getInputEventsPerWord( + startMs?: number, + testMsLimit?: number, +): Map { + let eventsPerWordIndex: Map = new Map(); + const events = getAllTestEvents(); + for (const event of events) { + if (event.type !== "input") { + continue; + } + + if (startMs !== undefined && event.testMs < startMs) { + continue; + } + + if (testMsLimit !== undefined && event.testMs > testMsLimit) { + break; + } + + let wordIndex = event.data.wordIndex; + + //special case for delete events on the 0th index + // because they affect the previous word - so we need to attribute them to the previous word + if ( + (event.data.inputType === "deleteWordBackward" || + event.data.inputType === "deleteContentBackward") && + event.data.charIndex === 0 && + wordIndex > 0 + ) { + wordIndex -= 1; + } + + const existing = eventsPerWordIndex.get(wordIndex) ?? []; + existing.push(event); + eventsPerWordIndex.set(wordIndex, existing); + } + return eventsPerWordIndex; +} + +export const __testing = { + resetPressedKeys(): void { + pressedKeys = new Map(); + noCodeIndex = 0; + }, +}; diff --git a/frontend/src/ts/test/events/helpers.ts b/frontend/src/ts/test/events/helpers.ts new file mode 100644 index 000000000000..415b4916520b --- /dev/null +++ b/frontend/src/ts/test/events/helpers.ts @@ -0,0 +1,117 @@ +import { Config } from "../../config/store"; +import { Keycode } from "../../constants/keys"; +import { InputEvent } from "./types"; + +export const keysToTrack = new Set([ + "NumpadMultiply", + "NumpadSubtract", + "NumpadAdd", + "NumpadDecimal", + "NumpadEqual", + "NumpadDivide", + "Numpad0", + "Numpad1", + "Numpad2", + "Numpad3", + "Numpad4", + "Numpad5", + "Numpad6", + "Numpad7", + "Numpad8", + "Numpad9", + "Backquote", + "Digit1", + "Digit2", + "Digit3", + "Digit4", + "Digit5", + "Digit6", + "Digit7", + "Digit8", + "Digit9", + "Digit0", + "Minus", + "Equal", + "KeyQ", + "KeyW", + "KeyE", + "KeyR", + "KeyT", + "KeyY", + "KeyU", + "KeyI", + "KeyO", + "KeyP", + "BracketLeft", + "BracketRight", + "Backslash", + "KeyA", + "KeyS", + "KeyD", + "KeyF", + "KeyG", + "KeyH", + "KeyJ", + "KeyK", + "KeyL", + "Semicolon", + "Quote", + "IntlBackslash", + "KeyZ", + "KeyX", + "KeyC", + "KeyV", + "KeyB", + "KeyN", + "KeyM", + "Comma", + "Period", + "Slash", + "Space", + "Enter", + "Tab", + "NoCode", //android (smells) and some keyboards might send no location data - need to use this as a fallback +]); + +export function getTestEventCode(event: KeyboardEvent): Keycode | "NoCode" { + if (event.code === "NumpadEnter" && Config.funbox.includes("58008")) { + return "Space"; + } + + if (event.code.includes("Arrow") && Config.funbox.includes("arrows")) { + return "NoCode"; + } + + if ( + event.code === "" || + event.code === undefined || + event.key === "Unidentified" + ) { + return "NoCode"; + } + + return event.code as Keycode; +} + +export function getSimulatedInput(events: InputEvent[]): string { + let simulatedInput = ""; + + for (const event of events) { + if (event.data.inputType === "insertText") { + if (event.data.inputStopped) continue; + simulatedInput += event.data.data; + } + if (event.data.inputType === "insertCompositionText") { + if (event.data.inputStopped) continue; + simulatedInput += event.data.data; + } + if (event.data.inputType === "deleteContentBackward") { + simulatedInput = simulatedInput.slice(0, -1); + } + if (event.data.inputType === "deleteWordBackward") { + simulatedInput = ""; + } + } + + return simulatedInput; +} diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts new file mode 100644 index 000000000000..3f8ae474ba7b --- /dev/null +++ b/frontend/src/ts/test/events/stats.ts @@ -0,0 +1,511 @@ +import { + getAllTestEvents, + getInputEvents, + getInputEventsPerWord, + getPressedKeys, + logTestEvent, +} from "./data"; +import * as TestWords from "../../test/test-words"; +import { CharCounts, countChars, getLastChar } from "../../utils/strings"; +import * as CustomText from "../../test/custom-text"; +import { getSimulatedInput } from "./helpers"; +import { activeWordIndex, bailedOut } from "../test-state"; +import { calculateWpm } from "../../utils/numbers"; +import { mean, roundTo2 } from "@monkeytype/util/numbers"; +import { InputEvent, TestEvent } from "./types"; +import { Config } from "../../config/store"; + +function getTimerBoundaries(events: TestEvent[]): number[] { + const boundaries: number[] = []; + let endMs: number | undefined; + + for (const event of events) { + if (event.type !== "timer") continue; + if (event.data.event === "step") { + boundaries.push(event.testMs); + } else if (event.data.event === "end") { + endMs = event.testMs; + } + } + + // in zen/bailout, cap to adjusted end to remove trailing afk seconds + if (endMs !== undefined && (Config.mode === "zen" || bailedOut)) { + const lkte = getRawLastKeypressToEndMs(); + if (lkte < 7000) { + endMs -= lkte; + // remove step boundaries past the adjusted end + while ( + boundaries.length > 0 && + (boundaries[boundaries.length - 1] as number) > endMs + ) { + boundaries.pop(); + } + } + } + + if (endMs !== undefined) { + const last = boundaries[boundaries.length - 1]; + if (endMs - (last ?? 0) >= 500) { + boundaries.push(endMs); + } + } + + return boundaries; +} + +export function getStartToFirstKeypressMs(): number { + if (Config.mode === "zen") return 0; + + const events = getAllTestEvents(); + + let firstKeypress: number | undefined; + let start: number | undefined; + + for (const event of events) { + if (firstKeypress !== undefined && start !== undefined) { + break; + } + + if (firstKeypress === undefined && event.type === "keydown") { + firstKeypress = event.testMs; + } + + if ( + start === undefined && + event.type === "timer" && + event.data.event === "start" + ) { + start = event.testMs; + } + } + + if (firstKeypress === undefined || start === undefined) { + return 0; + } + + const calc = firstKeypress - start; + return calc < 0 ? 0 : roundTo2(calc); +} + +// raw version is needed internally by getTestDurationMs to adjust +// duration in zen/bailout — the public version returns 0 for zen +function getRawLastKeypressToEndMs(): number { + const events = getAllTestEvents(); + + let lastKeypress: number | undefined; + let end: number | undefined; + + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + + if (event === undefined) { + // this is not possible, but typescript shouts at me + break; + } + + if (lastKeypress !== undefined && end !== undefined) { + break; + } + + if (lastKeypress === undefined && event.type === "keydown") { + lastKeypress = event.testMs; + } + + if ( + end === undefined && + event.type === "timer" && + event.data.event === "end" + ) { + end = event.testMs; + } + } + + if (lastKeypress === undefined || end === undefined) { + return 0; + } + + const calc = end - lastKeypress; + return calc < 0 ? 0 : roundTo2(calc); +} + +export function getLastKeypressToEndMs(): number { + if (Config.mode === "zen") return 0; + return getRawLastKeypressToEndMs(); +} + +function countPerInterval(predicate: (event: TestEvent) => boolean): { + counts: number[]; + boundaries: number[]; +} { + const events = getAllTestEvents(); + const boundaries = getTimerBoundaries(events); + + const counts: number[] = []; + let eventIndex = 0; + + for (const boundary of boundaries) { + let count = 0; + while (eventIndex < events.length) { + const event = events[eventIndex]; + if (event === undefined) break; + if (event.testMs > boundary) break; + + if (predicate(event)) { + count++; + } + eventIndex++; + } + counts.push(count); + } + + return { counts, boundaries }; +} + +export function getKeypressesPerSecond(): number[] { + const { counts } = countPerInterval( + (e) => e.type === "input" && e.data.inputType === "insertText", + ); + + return counts; +} + +export function getRawPerSecond(): number[] { + const { counts, boundaries } = countPerInterval( + (e) => e.type === "input" && e.data.inputType === "insertText", + ); + + let prevBoundary = 0; + return counts.map((kps, i) => { + const boundary = boundaries[i] as number; + const intervalSeconds = (boundary - prevBoundary) / 1000; + prevBoundary = boundary; + return Math.round(calculateWpm(kps, intervalSeconds)); + }); +} + +export function getTestDurationMs(): number { + const events = getAllTestEvents(); + + let end: number | undefined; + + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + + if (event === undefined) { + // this is not possible, but typescript shouts at me + break; + } + + if ( + end === undefined && + event.type === "timer" && + event.data.event === "end" + ) { + end = event.testMs; + break; + } + } + + if (end === undefined) { + return 0; + } + + if (Config.mode === "zen" || bailedOut) { + const lkte = getRawLastKeypressToEndMs(); + if (lkte < 7000) { + end -= lkte; + } + } + + if (Config.mode !== "custom") { + end = roundTo2(end / 1000) * 1000; + } + + return end; +} + +function getTargetWord( + wordIndex: number, + simulatedInput: string, + lastWord: boolean, +): string { + if (Config.mode === "zen") { + return simulatedInput; + } else { + const word = TestWords.words.getText(wordIndex); + + if (getLastChar(word) === "\n") { + // for multiline, dont add space + return word; + } + + return word + (lastWord ? "" : " "); + } +} + +export function getChars(): CharCounts { + const eventsPerWordIndex = getInputEventsPerWord(); + const isTimedTest = + Config.mode === "time" || + (Config.mode === "custom" && CustomText.getLimit().mode === "time"); + const shouldCountPartialLastWord = isTimedTest; + + let allCorrect = 0; + let correctWord = 0; + let incorrect = 0; + let extra = 0; + let missed = 0; + + for (const [wordIndex, events] of eventsPerWordIndex.entries()) { + const lastWord = wordIndex === activeWordIndex; + + let simulatedInput = getSimulatedInput(events); + + if (lastWord) { + //remove trailing space for last word + simulatedInput = simulatedInput.trimEnd(); + } + + const targetWord = getTargetWord(wordIndex, simulatedInput, lastWord); + + const charCounts = countChars( + simulatedInput, + targetWord, + lastWord, + shouldCountPartialLastWord, + ); + + allCorrect += charCounts.allCorrect; + correctWord += charCounts.correctWord; + incorrect += charCounts.incorrect; + extra += charCounts.extra; + missed += charCounts.missed; + + if (lastWord) { + break; + } + } + + return { + allCorrect: allCorrect, + correctWord: correctWord, + incorrect: incorrect, + extra: extra, + missed: missed, + }; +} + +export function getAccuracy(): { + correct: number; + incorrect: number; + percentage: number; +} { + const events = getInputEvents(); + + let correct = 0; + let incorrect = 0; + + for (const event of events) { + if (!("correct" in event.data)) { + continue; + } + if (event.data.correct) { + correct++; + } else { + incorrect++; + } + } + const total = correct + incorrect; + const percentage = total === 0 ? 0 : (correct / total) * 100; + + return { + correct: correct, + incorrect: incorrect, + percentage: percentage, + }; +} + +export function getKeypressSpacing(): number[] { + const events = getAllTestEvents(); + + const spacings: number[] = []; + let lastKeydownTime: number | undefined; + for (const event of events) { + if (event.type === "keydown") { + if (lastKeydownTime !== undefined) { + const spacing = event.ms - lastKeydownTime; + spacings.push(spacing); + } + lastKeydownTime = event.ms; + } + } + + return spacings; +} + +export function getKeypressOverlap(): number { + const events = getAllTestEvents(); + + const keydownTimes: Map< + string, + { + timestamp: number; + } + > = new Map(); + let overlap = 0; + let lastStartTime: number | undefined; + + for (const event of events) { + if (event.type === "keydown") { + keydownTimes.set(event.data.code, { + timestamp: event.ms, + }); + if (lastStartTime === undefined && keydownTimes.size > 1) { + lastStartTime = event.ms; + } + } else if (event.type === "keyup") { + keydownTimes.delete(event.data.code); + if (lastStartTime !== undefined && keydownTimes.size === 1) { + const endTime = event.ms; + overlap += endTime - lastStartTime; + lastStartTime = undefined; + } + } + } + return roundTo2(overlap); +} + +export function getErrorCountHistory(): number[] { + const { counts } = countPerInterval( + (e) => + e.type === "input" && + e.data.inputType === "insertText" && + !e.data.correct, + ); + return counts; +} + +export function getWpmHistory(): number[] { + const events = getAllTestEvents(); + const timerBoundaries = getTimerBoundaries(events); + const wpmHistory: number[] = []; + + for (const boundary of timerBoundaries) { + const eventsPerWord = getInputEventsPerWord(undefined, boundary); + + // Compute simulated inputs first so we can determine the effective last word + const wordInputs = new Map< + number, + { input: string; events: InputEvent[] } + >(); + let maxWordIndex = 0; + for (const [k, wordEvents] of eventsPerWord) { + const input = getSimulatedInput(wordEvents); + wordInputs.set(k, { input, events: wordEvents }); + // Only count words with non-empty input for maxWordIndex, + // so that fully-deleted words don't prevent earlier words + // from being treated as the last word + if (input.length > 0 && k > maxWordIndex) maxWordIndex = k; + } + + let totalCorrect = 0; + for (const [wordIndex, { input, events: wordEvents }] of wordInputs) { + if (input.length === 0) continue; + + const lastEvt = wordEvents[wordEvents.length - 1]; + let adjustedMax = maxWordIndex; + if ( + lastEvt !== undefined && + lastEvt.data.inputType === "insertText" && + lastEvt.data.data === " " + ) { + adjustedMax = maxWordIndex + 1; + } + const lastWord = wordIndex === adjustedMax; + + const trimmed = lastWord ? input.trimEnd() : input; + const targetWord = + Config.mode === "zen" + ? trimmed + : TestWords.words.getText(wordIndex) + (lastWord ? "" : " "); + totalCorrect += countChars( + trimmed, + targetWord, + lastWord, + true, + ).correctWord; + } + + const durationSeconds = boundary / 1000; + wpmHistory.push(Math.round(calculateWpm(totalCorrect, durationSeconds))); + } + + return wpmHistory; +} + +export function getAfkDuration(): number { + const { counts } = countPerInterval( + (e) => e.type === "keydown" || e.type === "input", + ); + return counts.reduce((total, c) => total + (c === 0 ? 1 : 0), 0); +} + +export function getKeypressDurations(): number[] { + const events = getAllTestEvents(); + + const keydownTimes: Map< + string, + { + timestamp: number; + index: number; + } + > = new Map(); + const durations: number[] = []; + + for (const event of events) { + if (event.type === "keydown") { + keydownTimes.set(event.data.code, { + timestamp: event.ms, + index: durations.length, + }); + durations.push(0); // placeholder + } else if (event.type === "keyup") { + const keydownTime = keydownTimes.get(event.data.code); + if (keydownTime !== undefined) { + const duration = event.ms - keydownTime.timestamp; + durations[keydownTime.index] = duration; + keydownTimes.delete(event.data.code); + } + } + } + + return durations; +} + +export function forceReleaseAllKeys(): void { + const filteredDurations = getKeypressDurations().filter((d) => d > 0); + + let avg: number; + if (filteredDurations.length === 0) { + // this means the test ended while all keys were still held - probably safe to ignore + // since this will result in a "too short" test anyway, but ill just set it to a magic number + avg = 80; + } else { + avg = roundTo2(mean(filteredDurations)); + } + + for (const [key, { timestamp }] of getPressedKeys().entries()) { + logTestEvent("keyup", timestamp + avg, { + code: key, //entries is not picking up the type + ctrl: false, + shift: false, + alt: false, + meta: false, + estimated: true, + }); + } +} + +export const __testing = { + getTimerBoundaries, +}; diff --git a/frontend/src/ts/test/events/types.ts b/frontend/src/ts/test/events/types.ts new file mode 100644 index 000000000000..94712b8dac63 --- /dev/null +++ b/frontend/src/ts/test/events/types.ts @@ -0,0 +1,100 @@ +import { Keycode } from "../../constants/keys"; +import { + DeleteInputType, + InsertInputType, +} from "../../input/helpers/input-type"; + +export type TestEventType = + | "keydown" + | "keyup" + | "input" + | "timer" + | "composition"; + +type EventProps = { + type: T; + ms: number; + testMs: number; + data: TData; +}; + +export type TestEvent = + | KeydownEvent + | KeyupEvent + | TimerEvent + | InputEvent + | CompositionTestEvent; + +export type TestEventData = + | KeydownEventData + | KeyupEventData + | TimerEventData + | InputEventData + | CompositionTestEventData; + +export type KeydownEvent = EventProps<"keydown", KeydownEventData>; + +export type KeydownEventData = { + code: Keycode | "NoCode" | `NoCode${number}`; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; +}; + +export type KeyupEvent = EventProps<"keyup", KeyupEventData>; + +export type KeyupEventData = { + code: Keycode | "NoCode" | `NoCode${number}`; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + estimated?: true; // true if this event never happened, but was estimated (force keyup on test end) +}; + +export type TimerEvent = EventProps<"timer", TimerEventData>; + +export type TimerEventData = + | { + event: "step"; + timer: number; + drift: number; + slowTimer?: true; + } + | { + event: "start" | "end"; + timer: number; + }; + +export type InputEvent = EventProps<"input", InputEventData>; + +export type InputEventData = { + charIndex: number; + wordIndex: number; +} & ( + | { + inputType: InsertInputType; + data: string; + correct: boolean; + isCompositionEnding: boolean; + inputStopped: boolean; + } + | { + inputType: DeleteInputType; + } +); + +export type CompositionTestEvent = EventProps< + "composition", + CompositionTestEventData +>; + +export type CompositionTestEventData = + | { + event: "start"; + } + | { + event: "update" | "end"; + data: string; + }; diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 53b459ca4dca..e74f8ba05b07 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -305,14 +305,16 @@ export function forceKeyup(now: number): void { const keypressDurations = keypressTimings.duration.array.filter( (_, index) => !indexesToRemove.has(index), ); + let avg: number; if (keypressDurations.length === 0) { // this means the test ended while all keys were still held - probably safe to ignore // since this will result in a "too short" test anyway - return; + // or we should use a magic number + avg = 80; + } else { + avg = roundTo2(mean(keypressDurations)); } - const avg = roundTo2(mean(keypressDurations)); - const orderedKeys = Object.entries(keyDownData).sort( (a, b) => a[1].timestamp - b[1].timestamp, ); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index e4fe5d35cd05..f90308dd691c 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -85,6 +85,24 @@ import { qs } from "../utils/dom"; import { setAccountButtonSpinner } from "../states/header"; import { Config } from "../config/store"; import { setQuoteLengthAll, toggleFunbox, setConfig } from "../config/setters"; +import { resetTestEvents, cleanupData } from "./events/data"; +import { + getKeypressDurations, + getChars, + getRawPerSecond, + getLastKeypressToEndMs, + getStartToFirstKeypressMs, + getTestDurationMs, + getAccuracy, + getKeypressSpacing, + getKeypressOverlap, + getErrorCountHistory, + getWpmHistory, + getAfkDuration, + forceReleaseAllKeys, + getKeypressesPerSecond, +} from "./events/stats"; +import { calculateWpm } from "../utils/numbers"; let failReason = ""; @@ -301,6 +319,7 @@ export function restart(options = {} as RestartOptions): void { PractiseWords.resetBefore(); } + resetTestEvents(); TestTimer.clear(); setIsTestInvalid(false); TestStats.restart(); @@ -882,11 +901,349 @@ function buildCompletedEvent( return completedEvent; } +function compareCompletedEvents( + ce: Omit, +): void { + const start = performance.now(); + const ce2 = buildCompletedEvent2(); + const end = performance.now(); + + console.debug( + `Built completed event 2 in ${Numbers.roundTo2(end - start)} ms`, + ); + + //compare ce and ce2, log differences + const notMatching: string[] = []; + const ceKeys = Object.keys(ce) as (keyof typeof ce)[]; + for (const key of ceKeys) { + let val1 = ce[key]; + let val2 = ce2[key]; + + if (key === "keyDuration" || key === "keySpacing") { + const a = (val1 as number[]).map((v) => Numbers.roundTo2(v)); + const b = (val2 as number[]).map((v) => Numbers.roundTo2(v)); + const total = Math.max(a.length, b.length); + let mismatchCount = 0; + if (a.length !== b.length) { + mismatchCount = total; + console.error( + `Completed event length mismatch on key ${key}: ${a.length} vs ${b.length}`, + ); + } else { + for (let i = 0; i < total; i++) { + if (a[i] !== b[i]) mismatchCount++; + } + } + if (mismatchCount === 0) { + console.debug(`Completed event match on key ${key}:`, a); + } else { + notMatching.push(`${key} (${mismatchCount}/${total} elements differ)`); + console.error( + `Completed event mismatch on key ${key}: ${mismatchCount}/${total} elements differ`, + a, + b, + ); + } + continue; + } + + if (key === "charStats") { + const a = val1 as number[]; + const b = val2 as number[]; + const labels = ["correct", "incorrect", "extra", "missed"]; + const diffs: string[] = []; + for (let i = 0; i < Math.max(a.length, b.length); i++) { + if (a[i] !== b[i]) { + const label = labels[i] ?? `[${i}]`; + diffs.push(`${label}: ${a[i]} vs ${b[i]}`); + } + } + if (diffs.length === 0) { + console.debug(`Completed event match on key charStats:`, a); + } else { + notMatching.push(`charStats (${diffs.join(", ")})`); + console.error(`Completed event mismatch on key charStats:`, a, b); + } + continue; + } + + if (key === "keyOverlap") { + val1 = Numbers.roundTo2(val1 as number); + val2 = Numbers.roundTo2(val2 as number); + } + + if (key === "timestamp") { + continue; + } + + if (key === "consistency") { + continue; + } + + // if (key === "chartData") { + // val1 = { + // //@ts-expect-error temp + // // eslint-disable-next-line + // wpm: (val1 as CompletedEvent["chartData"]).wpm.map((v) => + // // eslint-disable-next-line + // Math.round(v), + // ), + // //@ts-expect-error temp + // // eslint-disable-next-line + // burst: (val1 as CompletedEvent["chartData"]).burst, + // //@ts-expect-error temp + // // eslint-disable-next-line + // err: (val1 as CompletedEvent["chartData"]).err, + // }; + // val2 = { + // //@ts-expect-error temp + // // eslint-disable-next-line + // wpm: (val2 as CompletedEvent["chartData"]).wpm.map((v) => + // // eslint-disable-next-line + // Math.round(v), + // ), + // //@ts-expect-error temp + // // eslint-disable-next-line + // burst: (val2 as CompletedEvent["chartData"]).burst, + // //@ts-expect-error temp + // // eslint-disable-next-line + // err: (val2 as CompletedEvent["chartData"]).err, + // }; + // } + + if (key === "chartData") { + const v1 = val1 as CompletedEvent["chartData"]; + const v2 = val2 as CompletedEvent["chartData"]; + + if (v1 === "toolong" || v2 === "toolong") { + if (v1 === v2) { + console.debug( + `Completed event match on key chartData: both are "toolong"`, + ); + } else { + notMatching.push("chartData (one is 'toolong' and the other is not)"); + console.error( + `Completed event mismatch on key chartData: one is "toolong" and the other is not`, + v1, + v2, + ); + } + continue; + } + + for (const field of ["wpm", "err"] as const) { + const a = v1[field]; + const b = v2[field]; + const withinTolerance = + a.length === b.length && + a.every((val, i) => { + if (val === 0 && b[i] === 0) return true; + const ref = Math.max(Math.abs(val), Math.abs(b[i] ?? 0)); + return Math.abs(val - (b[i] ?? 0)) / ref <= 0.05; + }); + if (withinTolerance) { + console.debug(`Completed event match on key chartData.${field}:`, a); + } else { + notMatching.push(`chartData.${field} (values differ)`); + console.error( + `Completed event mismatch on key chartData.${field}:`, + a, + b, + ); + } + } + + { + const a = TestInput.keypressCountHistory; + const b = getKeypressesPerSecond(); + if (a.length === b.length && a.every((val, i) => val === b[i])) { + console.debug( + `Completed event match on key keypressCountHistory:`, + a, + ); + } else { + notMatching.push(`keypressCountHistory (values differ)`); + console.error( + `Completed event mismatch on key keypressCountHistory:`, + a, + b, + ); + } + } + } else if (key === "wpmConsistency" || key === "keyConsistency") { + const a = val1 as number; + const b = val2 as number; + const ref = Math.max( + Numbers.roundTo2(Math.abs(a)), + Numbers.roundTo2(Math.abs(b)), + ); + const within = (a === 0 && b === 0) || Math.abs(a - b) / ref <= 0.05; + if (within) { + console.debug(`Completed event match on key ${key}:`, a); + } else { + const diff = Numbers.roundTo2(Math.abs(a - b)); + notMatching.push(`${key} (off by ${diff})`); + console.error(`Completed event mismatch on key ${key}:`, a, b); + } + } else if (typeof val1 === "number" && typeof val2 === "number") { + const a = Numbers.roundTo2(val1); + const b = Numbers.roundTo2(val2); + if (a !== b) { + const diff = Numbers.roundTo2(Math.abs(a - b)); + notMatching.push(`${key} (off by ${diff})`); + console.error(`Completed event mismatch on key ${key}:`, a, b); + } else { + console.debug(`Completed event match on key ${key}:`, a); + } + } else if (JSON.stringify(val1) !== JSON.stringify(val2)) { + notMatching.push(`${key} (values differ)`); + console.error(`Completed event mismatch on key ${key}:`, val1, val2); + } else { + console.debug(`Completed event match on key ${key}:`, val1); + } + } + + if (notMatching.length === 0) { + // showSuccessNotification("Completed events match", { important: true }); + } else { + // showErrorNotification( + // `Completed event mismatch: ${notMatching.join(", ")}`, + // { important: true }, + // ); + Ape.results + .reportCompletedEventMismatch({ + body: { + notMatching, + // ce: ce as Record, + // ce2: ce2 as Record, + }, + }) + .catch(() => { + // + }); + } + + console.debug("Completed event object2", ce2); +} + +function buildCompletedEvent2(): Omit { + const chars = getChars(); + + //tags + const activeTagsIds: string[] = __nonReactive + .getActiveTags() + .map((tag) => tag._id); + + let language = Config.language; + if (Config.mode === "quote") { + language = Strings.removeLanguageSize(Config.language); + } + + let customText: CompletedEventCustomText | undefined = undefined; + if (Config.mode === "custom") { + const temp = CustomText.getData(); + customText = { + textLen: temp.text.length, + mode: temp.mode, + pipeDelimiter: temp.pipeDelimiter, + limit: temp.limit, + }; + } + + let duration = getTestDurationMs() / 1000; + + const rawPerSecond = getRawPerSecond(); + const afkDuration = getAfkDuration(); + const stddev = Numbers.stdDev(rawPerSecond); + const avg = Numbers.mean(rawPerSecond); + let consistency = Numbers.roundTo2(Numbers.kogasa(stddev / avg)); + if (!consistency || isNaN(consistency)) { + consistency = 0; + } + + const keypressSpacing = getKeypressSpacing(); + + let keyConsistencyArray = [...keypressSpacing]; + if (keypressSpacing.length > 0) { + keyConsistencyArray = keyConsistencyArray.slice( + 0, + keyConsistencyArray.length - 1, + ); + } + const keyStddev = Numbers.stdDev(keyConsistencyArray); + const keyAvg = Numbers.mean(keyConsistencyArray); + let keyConsistency = Numbers.roundTo2(Numbers.kogasa(keyStddev / keyAvg)); + if (!keyConsistency || isNaN(keyConsistency)) { + keyConsistency = 0; + } + + const wpmHistory = getWpmHistory(); + const wpmCons = Numbers.roundTo2( + Numbers.kogasa(Numbers.stdDev(wpmHistory) / Numbers.mean(wpmHistory)), + ); + const wpmConsistency = isNaN(wpmCons) ? 0 : wpmCons; + + const chartData = { + wpm: wpmHistory, + burst: rawPerSecond, + err: getErrorCountHistory(), + }; + + const completedEvent: Omit = { + wpm: Numbers.roundTo2(calculateWpm(chars.correctWord, duration)), + rawWpm: Numbers.roundTo2( + calculateWpm(chars.allCorrect + chars.incorrect + chars.extra, duration), + ), + charStats: [chars.correctWord, chars.incorrect, chars.extra, chars.missed], + charTotal: chars.allCorrect + chars.incorrect + chars.extra, + acc: Numbers.roundTo2(getAccuracy().percentage), + language: language, + testDuration: duration, + lastKeyToEnd: getLastKeypressToEndMs(), + startToFirstKey: getStartToFirstKeypressMs(), + afkDuration: afkDuration, + quoteLength: TestWords.currentQuote?.group ?? -1, + customText: customText, + tags: activeTagsIds, + punctuation: Config.punctuation, + numbers: Config.numbers, + lazyMode: Config.lazyMode, + timestamp: Date.now(), + mode: Config.mode, + mode2: Misc.getMode2(Config, TestWords.currentQuote), + bailedOut: TestState.bailedOut, + funbox: Config.funbox, + difficulty: Config.difficulty, + blindMode: Config.blindMode, + stopOnLetter: Config.stopOnError === "letter", + restartCount: TestState.restartCount, + incompleteTests: TestState.incompleteTests, + incompleteTestSeconds: + TestState.incompleteSeconds < 0 + ? 0 + : Numbers.roundTo2(TestState.incompleteSeconds), + + consistency: consistency, + wpmConsistency: wpmConsistency, + keyConsistency: keyConsistency, + chartData: chartData, + + keySpacing: keypressSpacing, + keyDuration: getKeypressDurations(), + keyOverlap: getKeypressOverlap(), + } as Omit; + + if (completedEvent.mode !== "custom") delete completedEvent.customText; + if (completedEvent.mode !== "quote") delete completedEvent.quoteLength; + + return completedEvent; +} + export async function finish(difficultyFailed = false): Promise { if (!TestState.isActive) return; TestState.setResultCalculating(true); const now = performance.now(); - TestTimer.clear(); + TestTimer.clear(true, now); TestStats.setEnd(now); // fade out the test and show loading @@ -921,6 +1278,7 @@ export async function finish(difficultyFailed = false): Promise { } TestInput.forceKeyup(now); //this ensures that the last keypress(es) are registered + forceReleaseAllKeys(); const endAfkSeconds = (now - TestInput.keypressTimings.spacing.last) / 1000; if ((Config.mode === "zen" || TestState.bailedOut) && endAfkSeconds < 7) { @@ -932,6 +1290,10 @@ export async function finish(difficultyFailed = false): Promise { TestState.setActive(false); Replay.stopReplayRecording(); + cleanupData(); + + // logEventsDataToTheConsoleTable(); + //need one more calculation for the last word if test auto ended if (TestInput.burstHistory.length !== TestInput.input.getHistory()?.length) { const burst = TestStats.calculateBurst(now); @@ -958,7 +1320,11 @@ export async function finish(difficultyFailed = false): Promise { PaceCaret.setLastTestWpm(stats.wpm); // if the last second was not rounded, add another data point to the history - if (TestStats.lastSecondNotRound && !difficultyFailed) { + if ( + TestStats.lastSecondNotRound && + !difficultyFailed && + Math.round(stats.time % 1) >= 0.5 + ) { const wpmAndRaw = TestStats.calculateWpmAndRaw(); TestInput.pushToWpmHistory(wpmAndRaw.wpm); TestInput.pushToRawHistory(wpmAndRaw.raw); @@ -988,6 +1354,10 @@ export async function finish(difficultyFailed = false): Promise { const ce = buildCompletedEvent(stats, rawPerSecond); + if (getAuthenticatedUser() !== null) { + compareCompletedEvents(ce); + } + console.debug("Completed event object", ce); function countUndefined(input: unknown): number { diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index 82c27c083657..7c802e807148 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -1,3 +1,4 @@ +import { IncompleteTest } from "@monkeytype/schemas/results"; import { promiseWithResolvers } from "../utils/misc"; export let isRepeated = false; @@ -12,6 +13,7 @@ export let isLanguageRightToLeft = false; export let isDirectionReversed = false; export let testRestarting = false; export let resultVisible = false; +export let restartCount = 0; export let resultCalculating = false; export function setRepeated(tf: boolean): void { @@ -80,6 +82,27 @@ export function setResultVisible(val: boolean): void { resultVisible = val; } +export function incrementRestartCount(): void { + restartCount++; +} + +export let incompleteSeconds = 0; +export let incompleteTests: IncompleteTest[] = []; + +export function incrementIncompleteSeconds(val: number): void { + incompleteSeconds += val; +} + +export function pushIncompleteTest(acc: number, seconds: number): void { + incompleteTests.push({ acc, seconds }); +} + +export function resetIncomplete(): void { + restartCount = 0; + incompleteSeconds = 0; + incompleteTests = []; +} + export function setResultCalculating(val: boolean): void { resultCalculating = val; } diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 0cab6f93d704..9d7701fc45f7 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -99,6 +99,10 @@ export function getStats(): unknown { export function restart(): void { start = 0; end = 0; + start2 = 0; + end2 = 0; + start3 = 0; + end3 = 0; lastSecondNotRound = false; } @@ -157,12 +161,7 @@ export function setStart(s: number): void { export function calculateAfkSeconds(testSeconds: number): number { let extraAfk = 0; if (testSeconds !== undefined) { - if (Config.mode === "time") { - extraAfk = - Math.round(testSeconds) - TestInput.keypressCountHistory.length; - } else { - extraAfk = Math.ceil(testSeconds) - TestInput.keypressCountHistory.length; - } + extraAfk = Math.round(testSeconds) - TestInput.keypressCountHistory.length; if (extraAfk < 0) extraAfk = 0; // console.log("-- extra afk debug"); // console.log("should be " + Math.ceil(testSeconds)); diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 1c8a6d1d9166..b7467e0dab62 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -28,6 +28,7 @@ import * as SoundController from "../controllers/sound-controller"; import { clearLowFpsMode, setLowFpsMode } from "../anim"; import { createTimer } from "animejs"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; +import { logTestEvent } from "./events/data"; let lastLoop = 0; const newTimer = createTimer({ @@ -38,10 +39,19 @@ const newTimer = createTimer({ lastLoop = performance.now(); }, onLoop: () => { - const drift = Math.abs(1000 - (performance.now() - lastLoop)); - lastLoop = performance.now(); + const now = performance.now(); + + const drift = Math.abs(1000 - (now - lastLoop)); checkIfTimerIsSlow(drift); + lastLoop = now; timerStep(); + + logTestEvent("timer", now, { + event: "step", + timer: Time.get(), + slowTimer: SlowTimer.get() ? true : undefined, + drift, + }); }, }); @@ -68,10 +78,16 @@ export function enableTimerDebug(): void { timerDebug = true; } -export function clear(): void { +export function clear(logEnd = false, now = performance.now()): void { clearLowFpsMode(); newTimer.reset(); if (timer !== null) clearTimeout(timer); + if (logEnd) { + logTestEvent("timer", now, { + event: "end", + timer: Time.get(), + }); + } } function premid(): void { @@ -310,11 +326,19 @@ export async function start(): Promise { async function _startNew(): Promise { newTimer.play(); + logTestEvent("timer", performance.now(), { + event: "start", + timer: Time.get(), + }); } async function _startOld(): Promise { timerStats = []; expected = TestStats.start + interval; + logTestEvent("timer", performance.now(), { + event: "start", + timer: Time.get(), + }); (function loop(): void { const delay = expected - performance.now(); timerStats.push({ @@ -333,6 +357,13 @@ async function _startOld(): Promise { return; } + logTestEvent("timer", performance.now(), { + event: "step", + timer: Time.get(), + drift: drift, + slowTimer: SlowTimer.get() ? true : undefined, + }); + timerStep(); expected += interval; diff --git a/frontend/src/ts/utils/numbers.ts b/frontend/src/ts/utils/numbers.ts index 7141d1e7a852..55ad64830d20 100644 --- a/frontend/src/ts/utils/numbers.ts +++ b/frontend/src/ts/utils/numbers.ts @@ -149,3 +149,11 @@ export function parseIntOptional( value !== null && value !== undefined ? parseInt(value, radix) : undefined ) as T extends string ? number : undefined; } + +export function calculateWpm( + charCount: number, + durationSeconds: number, +): number { + if (durationSeconds <= 0) return 0; + return charCount / 5 / (durationSeconds / 60); +} diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index e766eb29aac3..463da5ca46eb 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -400,6 +400,73 @@ export function replaceSpacesWithUnderscores(text: string): string { return text.replace(/ /g, "_"); } +export type CharCounts = { + allCorrect: number; + correctWord: number; + incorrect: number; + extra: number; + missed: number; +}; + +export function countChars( + inputWord: string, + targetWord: string, + lastWord: boolean, + shouldLastPartialWordCount: boolean, +): CharCounts { + let allCorrect = 0; + let correctWord = 0; + let incorrect = 0; + let extra = 0; + let missed = 0; + + const wordCorrect = inputWord === targetWord; + const wordPartiallyCorrect = targetWord.startsWith(inputWord); + + for (let i = 0; i < Math.max(inputWord.length, targetWord.length); i++) { + const inputChar = inputWord[i]; + const targetChar = targetWord[i]; + + if (inputChar === targetChar) { + // do not count correct space characters if the word is not correct + if (targetChar === " ") { + if (wordCorrect) { + allCorrect += 1; + } else { + incorrect += 1; + } + } else { + allCorrect += 1; + } + if ( + wordCorrect || + (lastWord && shouldLastPartialWordCount && wordPartiallyCorrect) + ) { + correctWord += 1; + } + } else if (inputChar === undefined) { + //missed char + if (!(lastWord && shouldLastPartialWordCount)) { + missed += 1; + } + } else if (targetChar === undefined) { + //extra char + extra += 1; + } else { + //incorrect char + incorrect += 1; + } + } + + return { + allCorrect, + correctWord, + incorrect, + extra, + missed, + }; +} + // Export testing utilities for unit tests export const __testing = { hasRTLCharacters, diff --git a/packages/contracts/src/rate-limit/index.ts b/packages/contracts/src/rate-limit/index.ts index f93b532b0011..5ce113103f81 100644 --- a/packages/contracts/src/rate-limit/index.ts +++ b/packages/contracts/src/rate-limit/index.ts @@ -165,6 +165,11 @@ export const limits = { max: 10, }, + resultsMismatchReport: { + window: 15 * 60 * 1000, // 15 min + max: 1, + }, + resultsLeaderboardGet: { window: "hour", max: 60, diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index cec92f4dadfc..010765f924f4 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -59,6 +59,15 @@ export const AddResultRequestSchema = z.object({ }); export type AddResultRequest = z.infer; +export const ReportCompletedEventMismatchRequestSchema = z.object({ + notMatching: z.array(z.string().max(100)).max(50), + // ce: z.record(z.unknown()), + // ce2: z.record(z.unknown()), +}); +export type ReportCompletedEventMismatchRequest = z.infer< + typeof ReportCompletedEventMismatchRequestSchema +>; + export const AddResultResponseSchema = responseWithData( PostResultResponseSchema, ); @@ -161,6 +170,20 @@ export const resultsContract = c.router( rateLimit: "resultsTagsUpdate", }), }, + reportCompletedEventMismatch: { + summary: "report completed event mismatch", + description: + "Report a mismatch between old and new completed event builders.", + method: "POST", + path: "/mismatch", + body: ReportCompletedEventMismatchRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: meta({ + rateLimit: "resultsMismatchReport", + }), + }, deleteAll: { summary: "delete all results", description: "Delete all results for the current user", From c321667569a2423b070745339f612dc18d32396e Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 29 May 2026 16:01:41 +0200 Subject: [PATCH 3/5] chore: fix merge --- frontend/src/ts/test/test-logic.ts | 21 ++++++++++++--------- frontend/src/ts/test/test-state.ts | 23 ----------------------- 2 files changed, 12 insertions(+), 32 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index f90308dd691c..5e18c91ef29a 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1216,12 +1216,10 @@ function buildCompletedEvent2(): Omit { difficulty: Config.difficulty, blindMode: Config.blindMode, stopOnLetter: Config.stopOnError === "letter", - restartCount: TestState.restartCount, - incompleteTests: TestState.incompleteTests, + restartCount: getRestartCount(), + incompleteTests: getIncompleteTests(), incompleteTestSeconds: - TestState.incompleteSeconds < 0 - ? 0 - : Numbers.roundTo2(TestState.incompleteSeconds), + getIncompleteSeconds() < 0 ? 0 : Numbers.roundTo2(getIncompleteSeconds()), consistency: consistency, wpmConsistency: wpmConsistency, @@ -1354,10 +1352,6 @@ export async function finish(difficultyFailed = false): Promise { const ce = buildCompletedEvent(stats, rawPerSecond); - if (getAuthenticatedUser() !== null) { - compareCompletedEvents(ce); - } - console.debug("Completed event object", ce); function countUndefined(input: unknown): number { @@ -1485,6 +1479,15 @@ export async function finish(difficultyFailed = false): Promise { // test is valid + if ( + getAuthenticatedUser() !== null && + !dontSave && + !difficultyFailed && + Config.resultSaving + ) { + compareCompletedEvents(ce); + } + if (TestState.isRepeated || difficultyFailed) { if (Config.resultSaving) { const testSeconds = completedEvent.testDuration; diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index 7c802e807148..82c27c083657 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -1,4 +1,3 @@ -import { IncompleteTest } from "@monkeytype/schemas/results"; import { promiseWithResolvers } from "../utils/misc"; export let isRepeated = false; @@ -13,7 +12,6 @@ export let isLanguageRightToLeft = false; export let isDirectionReversed = false; export let testRestarting = false; export let resultVisible = false; -export let restartCount = 0; export let resultCalculating = false; export function setRepeated(tf: boolean): void { @@ -82,27 +80,6 @@ export function setResultVisible(val: boolean): void { resultVisible = val; } -export function incrementRestartCount(): void { - restartCount++; -} - -export let incompleteSeconds = 0; -export let incompleteTests: IncompleteTest[] = []; - -export function incrementIncompleteSeconds(val: number): void { - incompleteSeconds += val; -} - -export function pushIncompleteTest(acc: number, seconds: number): void { - incompleteTests.push({ acc, seconds }); -} - -export function resetIncomplete(): void { - restartCount = 0; - incompleteSeconds = 0; - incompleteTests = []; -} - export function setResultCalculating(val: boolean): void { resultCalculating = val; } From 2ba2617b5c5912c9cb3a87e6c733ac5510b1b073 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 29 May 2026 16:39:27 +0200 Subject: [PATCH 4/5] chore: include additional fields in reportCompletedEventMismatch request --- backend/src/api/controllers/result.ts | 8 ++++++-- frontend/src/ts/test/test-logic.ts | 10 ++++++++-- packages/contracts/src/results.ts | 4 ++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index a2b500f5a9b3..769372c3845c 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -189,13 +189,17 @@ export async function reportCompletedEventMismatch( req: MonkeyRequest, ): Promise { const { uid } = req.ctx.decodedToken; - const { notMatching } = req.body; + const { notMatching, mode, mode2, difficulty, duration } = req.body; // Logger.warning( // `Completed event mismatch for uid ${uid}: ${notMatching.join(", ")}`, // ); // Logger.warning(`Old CE: ${JSON.stringify(ce)}`); // Logger.warning(`New CE: ${JSON.stringify(ce2)}`); - void addLog("completed_event_mismatch", { notMatching }, uid); + void addLog( + "completed_event_mismatch", + { notMatching, mode, mode2, difficulty, duration }, + uid, + ); return new MonkeyResponse("Mismatch reported", null); } diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 5e18c91ef29a..31e74a778964 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1082,7 +1082,8 @@ function compareCompletedEvents( console.debug(`Completed event match on key ${key}:`, a); } else { const diff = Numbers.roundTo2(Math.abs(a - b)); - notMatching.push(`${key} (off by ${diff})`); + const dir = a > b ? "ce1 larger" : "ce2 larger"; + notMatching.push(`${key} (off by ${diff}, ${dir})`); console.error(`Completed event mismatch on key ${key}:`, a, b); } } else if (typeof val1 === "number" && typeof val2 === "number") { @@ -1090,7 +1091,8 @@ function compareCompletedEvents( const b = Numbers.roundTo2(val2); if (a !== b) { const diff = Numbers.roundTo2(Math.abs(a - b)); - notMatching.push(`${key} (off by ${diff})`); + const dir = a > b ? "ce1 larger" : "ce2 larger"; + notMatching.push(`${key} (off by ${diff}, ${dir})`); console.error(`Completed event mismatch on key ${key}:`, a, b); } else { console.debug(`Completed event match on key ${key}:`, a); @@ -1114,6 +1116,10 @@ function compareCompletedEvents( .reportCompletedEventMismatch({ body: { notMatching, + mode: ce.mode, + mode2: ce.mode2, + difficulty: ce.difficulty, + duration: ce.testDuration, // ce: ce as Record, // ce2: ce2 as Record, }, diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index 010765f924f4..06d8030df1c6 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -61,6 +61,10 @@ export type AddResultRequest = z.infer; export const ReportCompletedEventMismatchRequestSchema = z.object({ notMatching: z.array(z.string().max(100)).max(50), + mode: z.string().optional(), + mode2: z.string().optional(), + difficulty: z.string().optional(), + duration: z.number().optional(), // ce: z.record(z.unknown()), // ce2: z.record(z.unknown()), }); From bc1673823adb3dc660c653b996f31f1e6949e3c4 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 29 May 2026 17:45:16 +0200 Subject: [PATCH 5/5] chore: shorter rate limit --- packages/contracts/src/rate-limit/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts/src/rate-limit/index.ts b/packages/contracts/src/rate-limit/index.ts index 5ce113103f81..a92e969dc4f1 100644 --- a/packages/contracts/src/rate-limit/index.ts +++ b/packages/contracts/src/rate-limit/index.ts @@ -166,7 +166,7 @@ export const limits = { }, resultsMismatchReport: { - window: 15 * 60 * 1000, // 15 min + window: 5 * 60 * 1000, // 15 min max: 1, },