diff --git a/frontend/__tests__/test/events/data.spec.ts b/frontend/__tests__/test/events/data.spec.ts index 987e054b53ab..1b2c22c5ae58 100644 --- a/frontend/__tests__/test/events/data.spec.ts +++ b/frontend/__tests__/test/events/data.spec.ts @@ -7,8 +7,7 @@ vi.mock("../../../src/ts/test/test-stats", () => ({ import { logTestEvent, getAllTestEvents, - getInputEvents, - getInputEventsPerWord, + getEventsPerWord, cleanupData, resetTestEvents, __testing, @@ -245,30 +244,31 @@ describe("data.ts", () => { }); }); - 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", () => { + describe("getEventsPerWord", () => { 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(); + const perWord = getEventsPerWord(); expect(perWord.get(0)).toHaveLength(2); expect(perWord.get(1)).toHaveLength(1); }); + it("includes composition events alongside input events", () => { + logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); + logTestEvent("composition", 1020, { event: "start", wordIndex: 0 }); + logTestEvent("keydown", 1025, keyDown()); + logTestEvent("composition", 1030, { + event: "update", + data: "a", + wordIndex: 0, + }); + + const perWord = getEventsPerWord(); + expect(perWord.get(0)).toHaveLength(3); + }); + it("does not shift delete at charIndex 0 if wordIndex is 0", () => { logTestEvent("input", 1010, { charIndex: 0, @@ -276,7 +276,7 @@ describe("data.ts", () => { inputType: "deleteContentBackward", } as InputEventData); - const perWord = getInputEventsPerWord(); + const perWord = getEventsPerWord(); expect(perWord.get(0)).toHaveLength(1); }); @@ -284,7 +284,7 @@ describe("data.ts", () => { logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); logTestEvent("input", 1100, inputData({ wordIndex: 0, charIndex: 1 })); - const perWord = getInputEventsPerWord(undefined, 50); + const perWord = getEventsPerWord(undefined, 50); expect(perWord.get(0)).toHaveLength(1); }); @@ -292,9 +292,10 @@ describe("data.ts", () => { logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); logTestEvent("input", 1100, inputData({ wordIndex: 0, charIndex: 1 })); - const perWord = getInputEventsPerWord(50); + const perWord = getEventsPerWord(50); expect(perWord.get(0)).toHaveLength(1); - expect(perWord.get(0)?.[0]?.data.charIndex).toBe(1); + const first = perWord.get(0)?.[0]; + expect(first?.type === "input" && first.data.charIndex).toBe(1); }); }); diff --git a/frontend/src/ts/input/listeners/composition.ts b/frontend/src/ts/input/listeners/composition.ts index 685df7dd4431..5fdbb6e36044 100644 --- a/frontend/src/ts/input/listeners/composition.ts +++ b/frontend/src/ts/input/listeners/composition.ts @@ -32,6 +32,7 @@ inputEl.addEventListener("compositionstart", (event) => { logTestEvent("composition", now, { event: "start", + wordIndex: TestState.activeWordIndex, }); }); @@ -50,6 +51,7 @@ inputEl.addEventListener("compositionupdate", (event) => { logTestEvent("composition", now, { event: "update", data: event.data, + wordIndex: TestState.activeWordIndex, }); }); @@ -75,5 +77,6 @@ inputEl.addEventListener("compositionend", async (event) => { logTestEvent("composition", now, { event: "end", data: event.data, + wordIndex: TestState.activeWordIndex, }); }); diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index 0bf8d7d1467a..ee0664d13cd5 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -7,7 +7,6 @@ import { KeydownEventData, KeyupEvent, KeyupEventData, - InputEventNoMs, TestEventData, TestEventNoMs, TestEventType, @@ -317,12 +316,6 @@ export function resetTestEvents(): void { noCodeIndex = 0; } -export function getInputEvents(): InputEventNoMs[] { - return getAllTestEvents().filter( - (event): event is InputEventNoMs => event.type === "input", - ); -} - export function getPressedKeys(): Map< Keycode | "NoCode" | `NoCode${number}`, { timestamp: number } @@ -330,14 +323,14 @@ export function getPressedKeys(): Map< return pressedKeys; } -export function getInputEventsPerWord( +export function getEventsPerWord( startMs?: number, testMsLimit?: number, -): Map { - let eventsPerWordIndex: Map = new Map(); +): Map { + let eventsPerWordIndex: Map = new Map(); const events = getAllTestEvents(); for (const event of events) { - if (event.type !== "input") { + if (!("wordIndex" in event.data)) { continue; } diff --git a/frontend/src/ts/test/events/helpers.ts b/frontend/src/ts/test/events/helpers.ts index 96302dbd8fcc..0e4bf58860b7 100644 --- a/frontend/src/ts/test/events/helpers.ts +++ b/frontend/src/ts/test/events/helpers.ts @@ -1,6 +1,6 @@ import { Config } from "../../config/store"; import { Keycode } from "../../constants/keys"; -import { InputEventNoMs } from "./types"; +import { InputEventNoMs, TestEventNoMs } from "./types"; export const keysToTrack = new Set([ "NumpadMultiply", @@ -120,34 +120,26 @@ export function applyInputEvent(input: string, event: InputEventNoMs): string { return input; } -/** - * Reads input from the DOM snapshots captured on each event (inputValue), - * falling back to op-based derivation for events without a snapshot. - * Use this whenever you need the actual current/past input state. - * - * Walks backward to find the latest event with a captured inputValue, then - * replays any subsequent events forward — O(1) when the last event has a - * snapshot (the common case), O(n) worst case. - */ -export function getInputFromDom(events: InputEventNoMs[]): string { - const lastEvent = events[events.length - 1]; +export function getInputFromDom(events: TestEventNoMs[]): string { + const lastInputEvent = events.findLast((e) => e.type === "input"); - if (lastEvent === undefined) { + if (lastInputEvent === undefined) { let input = ""; for (const event of events) { + if (event.type !== "input") continue; input = applyInputEvent(input, event); } return input; } - const inputValue = lastEvent.data.inputValue; + const inputValue = lastInputEvent.data.inputValue; if ( - lastEvent.data.inputType === "insertText" && - lastEvent.data.data === " " && - lastEvent.data.lastWord && - lastEvent.data.commitsWord && - !lastEvent.data.correct + lastInputEvent.data.inputType === "insertText" && + lastInputEvent.data.data === " " && + lastInputEvent.data.lastWord && + lastInputEvent.data.commitsWord && + !lastInputEvent.data.correct ) { // if this is an incorrect word commit on the last word, we dont want to count it at all return inputValue.trimEnd(); diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 98210c649ac3..b0db9c30a18c 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -1,7 +1,6 @@ import { getAllTestEvents, - getInputEvents, - getInputEventsPerWord, + getEventsPerWord, getPressedKeys, logTestEvent, } from "./data"; @@ -12,7 +11,7 @@ import { getInputFromDom } from "./helpers"; import { activeWordIndex, bailedOut, koreanStatus } from "../test-state"; import { calculateWpm } from "../../utils/numbers"; import { mean, roundTo2 } from "@monkeytype/util/numbers"; -import { InputEventNoMs, TestEventNoMs } from "./types"; +import { TestEventNoMs } from "./types"; import { Config } from "../../config/store"; import { isFunboxActiveWithProperty } from "../funbox/list"; import Hangul from "hangul-js"; @@ -266,7 +265,7 @@ function getTargetWord( } function countCharsForWords( - eventsPerWord: Map, + eventsPerWord: Map, lastWordIndex: number, shouldCountPartialLastWord: boolean, ): CharCounts { @@ -309,10 +308,10 @@ function countCharsForWords( } function inferActiveWordIndex( - eventsPerWord: Map, + eventsPerWord: Map, ): number { let maxWordIndex = -1; - let lastWordEvents: InputEventNoMs[] | undefined; + let lastWordEvents: TestEventNoMs[] | undefined; for (const [k, wordEvents] of eventsPerWord) { if (getInputFromDom(wordEvents).length > 0 && k > maxWordIndex) { maxWordIndex = k; @@ -324,6 +323,7 @@ function inferActiveWordIndex( // committed trailing space → cursor advanced to the next word if ( lastEvt !== undefined && + "inputType" in lastEvt.data && lastEvt.data.inputType === "insertText" && lastEvt.data.data === " " ) { @@ -338,14 +338,14 @@ export function getChars(): CharCounts { (Config.mode === "words" && Config.words === 0) || (Config.mode === "custom" && CustomText.getLimit().mode === "time"); return countCharsForWords( - getInputEventsPerWord(), + getEventsPerWord(), isTimedTest ? activeWordIndex : TestWords.words.list.length - 1, isTimedTest, ); } export function getInputHistory(): string[] { - const eventsPerWordIndex = getInputEventsPerWord(); + const eventsPerWordIndex = getEventsPerWord(); const history: string[] = []; for (const [wordIndex, events] of eventsPerWordIndex) { @@ -380,12 +380,14 @@ export function getAccuracy(): { incorrect: number; percentage: number; } { - const events = getInputEvents(); + const events = getAllTestEvents(); let correct = 0; let incorrect = 0; for (const event of events) { + if (event.type !== "input") continue; + if (!("correct" in event.data)) { continue; } @@ -472,7 +474,7 @@ export function getWpmHistory(): number[] { const wpmHistory: number[] = []; for (const boundary of getTimerBoundaries(events)) { - const eventsPerWord = getInputEventsPerWord(undefined, boundary); + const eventsPerWord = getEventsPerWord(undefined, boundary); const lastWordIndex = inferActiveWordIndex(eventsPerWord); const { correctWord } = countCharsForWords( eventsPerWord, diff --git a/frontend/src/ts/test/events/types.ts b/frontend/src/ts/test/events/types.ts index 4ca1ab2f2bdf..011fac963aca 100644 --- a/frontend/src/ts/test/events/types.ts +++ b/frontend/src/ts/test/events/types.ts @@ -113,8 +113,10 @@ export type CompositionTestEvent = EventProps< export type CompositionTestEventData = | { event: "start"; + wordIndex: number; } | { event: "update" | "end"; data: string; + wordIndex: number; };