diff --git a/frontend/__tests__/test/events/data.spec.ts b/frontend/__tests__/test/events/data.spec.ts index 14cbaaaccd79..572f3aa9f1e5 100644 --- a/frontend/__tests__/test/events/data.spec.ts +++ b/frontend/__tests__/test/events/data.spec.ts @@ -63,7 +63,7 @@ function timerData( if (event === "step") { return { event, timer, drift: 0 }; } - return { event, timer }; + return { event, timer, date: 0 }; } describe("data.ts", () => { diff --git a/frontend/__tests__/test/events/helpers.spec.ts b/frontend/__tests__/test/events/helpers.spec.ts index c79ec075c45d..6cd541f717b9 100644 --- a/frontend/__tests__/test/events/helpers.spec.ts +++ b/frontend/__tests__/test/events/helpers.spec.ts @@ -6,7 +6,9 @@ vi.mock("../../../src/ts/config/store", () => ({ })); import { - getSimulatedInput, + findInputValueMismatches, + getInputFromDom, + getInputFromEvents, getTestEventCode, } from "../../../src/ts/test/events/helpers"; import type { InputEvent } from "../../../src/ts/test/events/types"; @@ -91,65 +93,67 @@ function reset(): void { wordIndex = 0; } -describe("getSimulatedInput", () => { +describe("getInputFromEvents", () => { beforeEach(() => { reset(); }); it("builds string from insertText events", () => { - expect(getSimulatedInput([...insert("hello")])).toBe("hello"); + expect(getInputFromEvents([...insert("hello")])).toBe("hello"); }); it("builds string from insertText events with trailing space", () => { - expect(getSimulatedInput([...insert("hello ")])).toBe("hello "); + expect(getInputFromEvents([...insert("hello ")])).toBe("hello "); }); it("handles deleteContentBackward", () => { - expect(getSimulatedInput([...insert("abc"), ...deleteBackward()])).toBe( + expect(getInputFromEvents([...insert("abc"), ...deleteBackward()])).toBe( "ab", ); }); it("handles deleteContentBackward after space", () => { - expect(getSimulatedInput([...insert("abc "), ...deleteBackward()])).toBe( + expect(getInputFromEvents([...insert("abc "), ...deleteBackward()])).toBe( "abc", ); }); it("handles multiple deletes", () => { - expect(getSimulatedInput([...insert("ab"), ...deleteBackward(2)])).toBe(""); + expect(getInputFromEvents([...insert("ab"), ...deleteBackward(2)])).toBe( + "", + ); }); it("handles multiple deletes after space", () => { - expect(getSimulatedInput([...insert("ab "), ...deleteBackward(2)])).toBe( + expect(getInputFromEvents([...insert("ab "), ...deleteBackward(2)])).toBe( "a", ); }); it("handles deleteWordBackward", () => { - expect(getSimulatedInput([...insert("hello"), deleteWordBackward()])).toBe( + expect(getInputFromEvents([...insert("hello"), deleteWordBackward()])).toBe( "", ); }); it("handles deleteWordBackward after space", () => { - expect(getSimulatedInput([...insert("hello "), deleteWordBackward()])).toBe( - "", - ); + expect( + getInputFromEvents([...insert("hello "), deleteWordBackward()]), + ).toBe(""); }); it("returns empty string for no events", () => { - expect(getSimulatedInput([])).toBe(""); + expect(getInputFromEvents([])).toBe(""); }); it("handles deleteContentBackward on empty string", () => { const events = [...deleteBackward()]; - expect(getSimulatedInput(events)).toBe(""); + expect(getInputFromEvents(events)).toBe(""); }); it("skips inputStopped events", () => { expect( - getSimulatedInput([ + getInputFromEvents([ ...insert("he"), ...insert("x", "insertText", { inputStopped: true }), ...insert("llo"), @@ -157,21 +161,248 @@ describe("getSimulatedInput", () => { ).toBe("hello"); }); + it("handles deleteContentBackward within the same word correctly", () => { + expect(getInputFromEvents([...insert("a a"), deleteWordBackward()])).toBe( + "a ", + ); + }); + + it("handles deleteWordBackward with multiple internal spaces", () => { + expect( + getInputFromEvents([...insert("foo bar baz"), deleteWordBackward()]), + ).toBe("foo bar "); + }); + + it("handles deleteWordBackward with trailing space after multiple words", () => { + expect( + getInputFromEvents([...insert("foo bar "), deleteWordBackward()]), + ).toBe("foo "); + }); + + it("handles consecutive deleteWordBackward events", () => { + expect( + getInputFromEvents([ + ...insert("foo bar baz"), + deleteWordBackward(), + deleteWordBackward(), + ]), + ).toBe("foo "); + }); + + it("handles deleteWordBackward on empty string", () => { + expect(getInputFromEvents([deleteWordBackward()])).toBe(""); + }); + + it("handles deleteWordBackward on only whitespace", () => { + expect(getInputFromEvents([...insert(" "), deleteWordBackward()])).toBe( + "", + ); + }); + + it("ignores recorded inputValue (pure op-based simulation)", () => { + const events: InputEvent[] = [ + ...insert("hello"), + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "deleteWordBackward", + charIndex: 5, + wordIndex: 0, + inputValue: "RECORDED_BUT_IGNORED", + }, + }, + ]; + // pure simulation: deleteWordBackward on "hello" → "" + expect(getInputFromEvents(events)).toBe(""); + }); +}); + +describe("getInputFromDom", () => { + beforeEach(() => { + reset(); + }); + + it("falls through to op-based logic when inputValue is absent", () => { + expect(getInputFromDom([...insert("hello")])).toBe("hello"); + }); + + it("uses recorded inputValue when present, overriding op-based logic", () => { + const events: InputEvent[] = [ + ...insert("hello"), + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "deleteWordBackward", + charIndex: 5, + wordIndex: 0, + inputValue: "he", + }, + }, + ]; + // op-based would yield "", but inputValue is truth + expect(getInputFromDom(events)).toBe("he"); + }); + + it("uses latest event's inputValue across multiple recorded events", () => { + const events: InputEvent[] = [ + ...insert("hello"), + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "deleteContentBackward", + charIndex: 5, + wordIndex: 0, + inputValue: "hi", + }, + }, + ]; + expect(getInputFromDom(events)).toBe("hi"); + }); + + it("mixes captured and op-based across events", () => { + const events: InputEvent[] = [ + ...insert("ab"), // no inputValue, op = "ab" + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "insertText", + data: "c", + charIndex: 2, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + inputValue: "abc", + }, + }, + // next event has no inputValue, falls through to op (append "d") + { + type: "input", + ms: 110, + testMs: 110, + data: { + inputType: "insertText", + data: "d", + charIndex: 3, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + }, + }, + ]; + expect(getInputFromDom(events)).toBe("abcd"); + }); +}); + +describe("findInputValueMismatches", () => { + beforeEach(() => { + reset(); + }); + + it("returns empty when no events have recorded inputValue", () => { + expect(findInputValueMismatches([...insert("hello")])).toEqual([]); + }); + + it("returns empty when recorded values match derivation", () => { + const events: InputEvent[] = [ + { + type: "input", + ms: 10, + testMs: 10, + data: { + inputType: "insertText", + data: "a", + charIndex: 0, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + inputValue: "a", + }, + }, + { + type: "input", + ms: 20, + testMs: 20, + data: { + inputType: "insertText", + data: "b", + charIndex: 1, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + inputValue: "ab", + }, + }, + ]; + expect(findInputValueMismatches(events)).toEqual([]); + }); + + it("returns mismatches when recorded value differs from derivation", () => { + const events: InputEvent[] = [ + { + type: "input", + ms: 10, + testMs: 10, + data: { + inputType: "insertText", + data: "a", + charIndex: 0, + wordIndex: 0, + correct: true, + isCompositionEnding: false, + inputStopped: false, + inputValue: "DIFFERENT", + }, + }, + ]; + expect(findInputValueMismatches(events)).toEqual([ + { index: 0, derived: "a", recorded: "DIFFERENT" }, + ]); + }); + + it("skips events without inputValue, still tracks ones with it", () => { + const events: InputEvent[] = [ + ...insert("hello"), // no inputValue on these + { + type: "input", + ms: 100, + testMs: 100, + data: { + inputType: "deleteContentBackward", + charIndex: 5, + wordIndex: 0, + inputValue: "hell", + }, + }, + ]; + // derivation: "hello" then slice = "hell". Recorded = "hell". Match. + expect(findInputValueMismatches(events)).toEqual([]); + }); + // it("handles insertCompositionText events", () => { // const events = [ // ...insert("k", "insertCompositionText"), // ...insert("ka", "insertCompositionText"), // ]; - // expect(getSimulatedInput(events)).toBe("ka"); + // expect(getInputFromEvents(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"); + // expect(getInputFromEvents(events)).toBe("kab"); // }); }); diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 3c7d03a55a32..4a647dd650b8 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -55,6 +55,7 @@ import { getChars, getWpmHistory, forceReleaseAllKeys, + getCorrectedWords, __testing as statsTesting, } from "../../../src/ts/test/events/stats"; import type { @@ -112,7 +113,7 @@ function timer( if (event === "step") { return { event, timer: timerVal, drift: 0 }; } - return { event, timer: timerVal }; + return { event, timer: timerVal, date: 0 }; } // Helper: sets up a basic test with timer start, steps at 1s intervals, @@ -677,6 +678,284 @@ describe("stats.ts", () => { }); }); + describe("getCorrectedWords", () => { + it("returns input as-is when no corrections made", () => { + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "t" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "e" }), + ); + logTestEvent( + "input", + 1200, + input({ charIndex: 2, wordIndex: 0, data: "s" }), + ); + logTestEvent( + "input", + 1250, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + + expect(getCorrectedWords()).toEqual(["test"]); + }); + + it("returns last deleted char per position (xact -> fact)", () => { + logTestEvent("timer", 1000, timer("start", 0)); + // type "xact" + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "x" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "a" }), + ); + logTestEvent( + "input", + 1200, + input({ charIndex: 2, wordIndex: 0, data: "c" }), + ); + logTestEvent( + "input", + 1250, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + // delete all + logTestEvent("input", 1300, { + charIndex: 3, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent("input", 1350, { + charIndex: 2, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent("input", 1400, { + charIndex: 1, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent("input", 1450, { + charIndex: 0, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + // type "fact" + logTestEvent( + "input", + 1500, + input({ charIndex: 0, wordIndex: 0, data: "f" }), + ); + logTestEvent( + "input", + 1550, + input({ charIndex: 1, wordIndex: 0, data: "a" }), + ); + logTestEvent( + "input", + 1600, + input({ charIndex: 2, wordIndex: 0, data: "c" }), + ); + logTestEvent( + "input", + 1650, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + + expect(getCorrectedWords()).toEqual(["xact"]); + }); + + it("returns last deleted char per position across multiple corrections (xest -> west -> test)", () => { + logTestEvent("timer", 1000, timer("start", 0)); + // type "xest" + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "x" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "e" }), + ); + logTestEvent( + "input", + 1200, + input({ charIndex: 2, wordIndex: 0, data: "s" }), + ); + logTestEvent( + "input", + 1250, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + // delete all + logTestEvent("input", 1300, { + charIndex: 3, + wordIndex: 0, + inputType: "deleteWordBackward", + } as InputEventData); + // type "west" + logTestEvent( + "input", + 1400, + input({ charIndex: 0, wordIndex: 0, data: "w" }), + ); + logTestEvent( + "input", + 1450, + input({ charIndex: 1, wordIndex: 0, data: "e" }), + ); + logTestEvent( + "input", + 1500, + input({ charIndex: 2, wordIndex: 0, data: "s" }), + ); + logTestEvent( + "input", + 1550, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + // delete all + logTestEvent("input", 1600, { + charIndex: 3, + wordIndex: 0, + inputType: "deleteWordBackward", + } as InputEventData); + // type "test" + logTestEvent( + "input", + 1700, + input({ charIndex: 0, wordIndex: 0, data: "t" }), + ); + logTestEvent( + "input", + 1750, + input({ charIndex: 1, wordIndex: 0, data: "e" }), + ); + logTestEvent( + "input", + 1800, + input({ charIndex: 2, wordIndex: 0, data: "s" }), + ); + logTestEvent( + "input", + 1850, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + + expect(getCorrectedWords()).toEqual(["west"]); + }); + + it("handles partial correction (tset -> delete last 2 -> st)", () => { + logTestEvent("timer", 1000, timer("start", 0)); + // type "tset" + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "t" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "s" }), + ); + logTestEvent( + "input", + 1200, + input({ charIndex: 2, wordIndex: 0, data: "e" }), + ); + logTestEvent( + "input", + 1250, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + // delete last 2 + logTestEvent("input", 1300, { + charIndex: 3, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent("input", 1350, { + charIndex: 2, + wordIndex: 0, + inputType: "deleteContentBackward", + } as InputEventData); + // type "st" + logTestEvent( + "input", + 1400, + input({ charIndex: 2, wordIndex: 0, data: "s" }), + ); + logTestEvent( + "input", + 1450, + input({ charIndex: 3, wordIndex: 0, data: "t" }), + ); + + // pos 0: "t" never deleted, pos 1: "s" never deleted, pos 2: "e" deleted, pos 3: "t" deleted + expect(getCorrectedWords()).toEqual(["tset"]); + }); + + it("handles multiple words", () => { + logTestEvent("timer", 1000, timer("start", 0)); + // word 0: type "ab" correctly + logTestEvent( + "input", + 1100, + input({ charIndex: 0, wordIndex: 0, data: "a" }), + ); + logTestEvent( + "input", + 1150, + input({ charIndex: 1, wordIndex: 0, data: "b" }), + ); + // word 1: type "xy", delete both, type "zw" + logTestEvent( + "input", + 1200, + input({ charIndex: 0, wordIndex: 1, data: "x" }), + ); + logTestEvent( + "input", + 1250, + input({ charIndex: 1, wordIndex: 1, data: "y" }), + ); + logTestEvent("input", 1300, { + charIndex: 1, + wordIndex: 1, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent("input", 1350, { + charIndex: 1, + wordIndex: 1, + inputType: "deleteContentBackward", + } as InputEventData); + logTestEvent( + "input", + 1400, + input({ charIndex: 0, wordIndex: 1, data: "z" }), + ); + logTestEvent( + "input", + 1450, + input({ charIndex: 1, wordIndex: 1, data: "w" }), + ); + + const result = getCorrectedWords(); + expect(result[0]).toEqual("ab"); + expect(result[1]).toEqual("xy"); + }); + }); + describe("forceReleaseAllKeys", () => { it("creates synthetic keyup events for pressed keys", () => { logTestEvent("timer", 1000, timer("start", 0)); diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index e7e62a75f307..72b86d0a5f3c 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -22,13 +22,13 @@ import * as getErrorMessage from "../utils/error"; import * as JSONData from "../utils/json-data"; import { randomizeTheme } from "../controllers/theme-controller"; import { showModal } from "../states/modals"; +import * as TestWords from "../test/test-words"; import { showErrorNotification, - showSuccessNotification, clearAllNotifications, + showSuccessNotification, } from "../states/notifications"; import * as VideoAdPopup from "../popups/video-ad-popup"; -import * as TestStats from "../test/test-stats"; import { Command, CommandsSubgroup } from "./types"; import { buildCommandForConfigKey } from "./util"; import { CommandlineConfigMetadataObject } from "./commandline-metadata"; @@ -40,6 +40,7 @@ import { showFpsCounter, } from "../components/layout/overlays/FpsCounter"; import { applyConfigFromJson } from "../config/lifecycle"; +import { getAllTestEvents } from "../test/events/data"; const challengesPromise = JSONData.getChallengeList(); challengesPromise @@ -290,12 +291,18 @@ export const commands: CommandsSubgroup = { }, { id: "copyResultStats", - display: "Copy result stats", + display: "Copy result data", + alias: "stats events", icon: "fa-cog", visible: false, exec: async (): Promise => { navigator.clipboard - .writeText(JSON.stringify(TestStats.getStats())) + .writeText( + JSON.stringify({ + events: getAllTestEvents(), + words: TestWords.words, + }), + ) .then(() => { showSuccessNotification("Copied to clipboard"); }) diff --git a/frontend/src/ts/commandline/lists/result-screen.ts b/frontend/src/ts/commandline/lists/result-screen.ts index 1debee3ac804..2f5017aabd4d 100644 --- a/frontend/src/ts/commandline/lists/result-screen.ts +++ b/frontend/src/ts/commandline/lists/result-screen.ts @@ -5,13 +5,13 @@ import { showErrorNotification, showSuccessNotification, } from "../../states/notifications"; -import * as TestInput from "../../test/test-input"; import * as TestState from "../../test/test-state"; import * as TestWords from "../../test/test-words"; import { Config } from "../../config/store"; import * as PractiseWords from "../../test/practise-words"; import { Command, CommandsSubgroup } from "../types"; import * as TestScreenshot from "../../test/test-screenshot"; +import { getInputHistory } from "../../test/events/stats"; const practiceSubgroup: CommandsSubgroup = { title: "Practice words...", @@ -141,8 +141,8 @@ const commands: Command[] = [ exec: (): void => { const words = ( Config.mode === "zen" - ? TestInput.input.getHistory() - : TestWords.words.list.slice(0, TestInput.input.getHistory().length) + ? getInputHistory() + : TestWords.words.list.slice(0, getInputHistory().length) ).join(" "); navigator.clipboard.writeText(words).then( diff --git a/frontend/src/ts/controllers/chart-controller.ts b/frontend/src/ts/controllers/chart-controller.ts index 572c5060ee24..2142abf340c4 100644 --- a/frontend/src/ts/controllers/chart-controller.ts +++ b/frontend/src/ts/controllers/chart-controller.ts @@ -58,13 +58,13 @@ Chart.defaults.elements.line.fill = "origin"; import "chartjs-adapter-date-fns"; import { Config } from "../config/store"; import { configEvent } from "../events/config"; -import * as TestInput from "../test/test-input"; import * as Arrays from "../utils/arrays"; import { blendTwoHexColors } from "../utils/colors"; import { typedKeys } from "../utils/misc"; import { getTheme } from "../states/theme"; import { Theme } from "../constants/themes"; import { createDebouncedEffectOn } from "../hooks/effects"; +import { getWordIndexesForSecond } from "../test/events/stats"; export class ChartWithUpdateColors< TType extends ChartType = ChartType, @@ -274,8 +274,7 @@ export const result = new ChartWithUpdateColors< prevTi = ti; try { const keypressIndex = Math.round(parseFloat(ti.label)) - 1; - const wordsToHighlight = - TestInput.errorHistory[keypressIndex]?.words; + const wordsToHighlight = getWordIndexesForSecond(keypressIndex); const unique = [...new Set(wordsToHighlight)]; const firstHighlightWordIndex = unique[0]; diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index 10a5f7efaf93..9021650cbd66 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -15,8 +15,6 @@ import * as DB from "./db"; import "./ui"; import "./controllers/ad-controller"; import { Config } from "./config/store"; -import * as TestStats from "./test/test-stats"; -import * as Replay from "./test/replay"; import * as TestTimer from "./test/test-timer"; import * as Result from "./test/result"; import { onAuthStateChanged } from "./auth"; @@ -88,8 +86,6 @@ addToGlobal({ snapshot: DB.getSnapshot, config: Config, glarsesMode: enable, - stats: TestStats.getStats, - replay: Replay.getReplayExport, enableTimerDebug: TestTimer.enableTimerDebug, getTimerStats: TestTimer.getTimerStats, toggleSmoothedBurst: Result.toggleSmoothedBurst, diff --git a/frontend/src/ts/input/handlers/before-delete.ts b/frontend/src/ts/input/handlers/before-delete.ts index 3dc7c77a1c73..d623cdd610a5 100644 --- a/frontend/src/ts/input/handlers/before-delete.ts +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -1,10 +1,10 @@ import { Config } from "../../config/store"; -import * as TestInput from "../../test/test-input"; import * as TestState from "../../test/test-state"; import * as TestWords from "../../test/test-words"; import { getInputElementValue } from "../input-element"; import * as TestUI from "../../test/test-ui"; import { isAwaitingNextWord } from "../state"; +import { getInputForWord } from "../../test/events/stats"; export function onBeforeDelete(event: InputEvent): void { if (!TestState.isActive) { @@ -28,6 +28,12 @@ export function onBeforeDelete(event: InputEvent): void { const inputIsEmpty = inputValue === ""; if (inputIsEmpty) { + // we are on the first word, just prevent default, nothing to go back to + if (TestState.activeWordIndex === 0) { + event.preventDefault(); + return; + } + // this is nested because we only wanna pull the element from the dom if needed const previousWordElement = TestUI.getWordElement( TestState.activeWordIndex - 1, @@ -45,7 +51,7 @@ export function onBeforeDelete(event: InputEvent): void { const confidence = Config.confidenceMode; const previousWordCorrect = - (TestInput.input.get(TestState.activeWordIndex - 1) ?? "") === + getInputForWord(TestState.activeWordIndex - 1) === TestWords.words.getText(TestState.activeWordIndex - 1); if (confidence === "on" && inputIsEmpty && !previousWordCorrect) { diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 3b96d6be403c..3c5f56419e6e 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -1,5 +1,5 @@ import { Config } from "../../config/store"; -import * as TestInput from "../../test/test-input"; +import { getCurrentInput } from "../../test/events/data"; import * as TestState from "../../test/test-state"; import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; @@ -61,7 +61,7 @@ export function onBeforeInsertText(data: string): boolean { // block input if the word is too long const inputLimit = Config.mode === "zen" ? 30 : TestWords.words.getCurrentText().length + 20; - const overLimit = TestInput.input.current.length >= inputLimit; + const overLimit = getCurrentInput().length >= inputLimit; if (overLimit && (shouldInsertSpaceAsCharacter === true || !dataIsSpace)) { console.error("Hitting word limit"); return true; @@ -71,7 +71,7 @@ export function onBeforeInsertText(data: string): boolean { // this will not work for the first word of each line, but that has a low chance of happening const dataIsNotFalsy = data !== null && data !== ""; const inputIsLongerThanOrEqualToWord = - TestInput.input.current.length >= TestWords.words.getCurrentText().length; + getCurrentInput().length >= TestWords.words.getCurrentText().length; if ( !SlowTimer.get() && // don't do this check if slow timer is active @@ -91,7 +91,7 @@ export function onBeforeInsertText(data: string): boolean { ); const { top: topAfterAppend, height: heightAfterAppend } = TestUI.getActiveWordTopAndHeightWithDifferentData( - (pendingWordData ?? TestInput.input.current) + data, + (pendingWordData ?? getCurrentInput()) + data, ); if (topAfterAppend > TestUI.activeWordTop) { //word jumped to next line diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index 8d2a44340a47..20a4a267aea5 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -1,30 +1,25 @@ import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; -import * as TestInput from "../../test/test-input"; import { getInputElementValue, setInputElementValue } from "../input-element"; -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 { getCurrentInput, logTestEvent } from "../../test/events/data"; import { activeWordIndex } from "../../test/test-state"; export function onDelete(inputType: DeleteInputType, now: number): void { const { realInputValue } = getInputElementValue(); - const inputBeforeDelete = TestInput.input.current; + const inputBeforeDelete = getCurrentInput(); const activeWordIndexBeforeDelete = activeWordIndex; - TestInput.input.syncWithInputElement(); - - Replay.addReplayEvent("setLetterIndex", TestInput.input.current.length); - TestInput.setCurrentNotAfk(); + const inputAfterDelete = getInputElementValue().inputValue; const beforeDeleteOnlyTabs = /^\t*$/.test(inputBeforeDelete); const allTabsCorrect = TestWords.words .getCurrentText() - .startsWith(TestInput.input.current); + .startsWith(inputAfterDelete); //special check for code languages if ( @@ -33,25 +28,52 @@ export function onDelete(inputType: DeleteInputType, now: number): void { inputBeforeDelete.length > 0 && beforeDeleteOnlyTabs && allTabsCorrect - // (TestInput.input.getHistory(TestState.activeWordIndex - 1) !== - // TestWords.words.get(TestState.activeWordIndex - 1) || - // Config.freedomMode) ) { + // Clear N+1's tabs (the word the user was in) + logTestEvent("input", now, { + inputType: "deleteWordBackward", + wordIndex: activeWordIndexBeforeDelete, + charIndex: inputBeforeDelete.length, + inputValue: "", + }); + setInputElementValue(""); - TestInput.input.syncWithInputElement(); goToPreviousWord(inputType, true); - } else { - //normal backspace - if (realInputValue === "") { - goToPreviousWord(inputType); - } + + // Record the resulting state of the previous word (newline removed) + const postNavInputValue = getInputElementValue().inputValue; + logTestEvent("input", now, { + inputType: "deleteContentBackward", + wordIndex: activeWordIndex, + charIndex: postNavInputValue.length, + inputValue: postNavInputValue, + }); + + TestUI.afterTestDelete(); + return; } - logTestEvent("input", now, { - inputType: inputType, - wordIndex: activeWordIndexBeforeDelete, - charIndex: inputBeforeDelete.length, - }); + //normal backspace + if (realInputValue === "") { + goToPreviousWord(inputType); + + // Record the resulting state of the destination word + const postNavInputValue = getInputElementValue().inputValue; + logTestEvent("input", now, { + inputType: inputType, + wordIndex: activeWordIndex, + charIndex: postNavInputValue.length, + inputValue: postNavInputValue, + }); + } else { + // Delete within current word + logTestEvent("input", now, { + inputType: inputType, + wordIndex: activeWordIndexBeforeDelete, + charIndex: inputBeforeDelete.length, + inputValue: inputAfterDelete, + }); + } TestUI.afterTestDelete(); } diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 6145641c5a49..53af39c1fdb9 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -1,6 +1,5 @@ import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; -import * as TestInput from "../../test/test-input"; import { getInputElementValue, replaceInputElementLastValueChar, @@ -19,7 +18,6 @@ import { findSingleActiveFunboxWithFunction, isFunboxActiveWithProperty, } from "../../test/funbox/list"; -import * as Replay from "../../test/replay"; import { Config } from "../../config/store"; import { flash } from "../../events/keymap"; import * as WeakSpot from "../../test/weak-spot"; @@ -37,7 +35,7 @@ import { isCharCorrect, shouldInsertSpaceCharacter, } from "../helpers/validation"; -import { logTestEvent } from "../../test/events/data"; +import { getCurrentInput, logTestEvent } from "../../test/events/data"; const charOverrides = new Map([ ["…", "..."], @@ -66,10 +64,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { if (options.data.length > 1) { // remove the entire data from the input value - // make sure to not call TestInput.input.syncWithInputElement in here - // it will be updated later in the body of onInsertText setInputElementValue(inputValue.slice(0, -options.data.length)); - TestInput.input.syncWithInputElement(); for (let i = 0; i < options.data.length; i++) { const char = options.data[i] as string; @@ -86,8 +81,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const charOverride = charOverrides.get(options.data); if ( charOverride !== undefined && - TestWords.words.getCurrentText()[TestInput.input.current.length] !== - options.data + TestWords.words.getCurrentText()[getCurrentInput().length] !== options.data ) { // replace the data with the override setInputElementValue( @@ -101,7 +95,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { } // input and target word - const testInput = TestInput.input.current; + const testInput = getCurrentInput(); const currentWord = TestWords.words.getCurrentText(); // if the character is visually equal, replace it with the target character @@ -156,31 +150,11 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const shouldGoToNextWord = ((charIsSpace || charIsNewline) && !shouldInsertSpace) || noSpaceForce; - // update test input state - if (!charIsSpace || shouldInsertSpace) { - TestInput.input.syncWithInputElement(); - } - // general per keypress updates - TestInput.setCurrentNotAfk(); - Replay.addReplayEvent(correct ? "correctLetter" : "incorrectLetter", data); - TestInput.incrementAccuracy(correct); WeakSpot.updateScore(data, correct); - TestInput.incrementKeypressCount(); - TestInput.pushKeypressWord(wordIndex); - if (!correct) { - TestInput.incrementKeypressErrors(); - TestInput.pushMissedWord(TestWords.words.getCurrentText()); - } if (Config.keymapMode === "react") { flash(data, correct); } - if (testInput.length === 0 && !isCompositionEnding) { - TestInput.setBurstStart(now); - } - if (!shouldGoToNextWord) { - TestInput.corrected.update(data, correct); - } // handing cases where last char needs to be removed // this is here and not in beforeInsertText because we want to penalize for incorrect spaces @@ -210,9 +184,26 @@ export async function onInsertText(options: OnInsertTextParams): Promise { if (removeLastChar) { replaceInputElementLastValueChar(""); - TestInput.input.syncWithInputElement(); } + // capture DOM before goToNextWord clears it for the new word + const inputValueAfterEvent = getInputElementValue().inputValue; + + // Log the event BEFORE goToNextWord so readers inside the navigation + // (e.g. beforeTestWordChange's updateWordLetters, getWordBurst) see the + // completed event in derivation. Otherwise the just-typed trigger char + // (space/newline) is missing — visible as missing \n element in zen mode. + logTestEvent("input", now, { + inputType: "insertText", + data, + correct, + wordIndex, + charIndex: testInput.length, + isCompositionEnding: isCompositionEnding === true, + inputStopped: removeLastChar, + inputValue: inputValueAfterEvent, + }); + // going to next word let increasedWordIndex: null | boolean = null; let lastBurst: null | number = null; @@ -221,21 +212,12 @@ export async function onInsertText(options: OnInsertTextParams): Promise { correctInsert: correct, isCompositionEnding: isCompositionEnding === true, zenNewline: charIsNewline && Config.mode === "zen", + now, }); lastBurst = result.lastBurst; 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? @@ -249,7 +231,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { //this COULD be the next word because we are awaiting goToNextWord const nextWord = TestWords.words.getCurrentText(); const doesNextWordHaveTab = /^\t+/.test(nextWord); - const isCurrentCharTab = nextWord[TestInput.input.current.length] === "\t"; + const isCurrentCharTab = nextWord[getCurrentInput().length] === "\t"; //code mode - auto insert tabs if ( @@ -326,8 +308,6 @@ export async function emulateInsertText( } // default is prevented so we need to manually update the input value. - // remember to not call TestInput.input.syncWithInputElement in here - // it will be called later be updated in onInsertText appendToInputElementValue(options.data); await onInsertText(options); diff --git a/frontend/src/ts/input/handlers/keydown.ts b/frontend/src/ts/input/handlers/keydown.ts index a2beca801445..9194d3ca7419 100644 --- a/frontend/src/ts/input/handlers/keydown.ts +++ b/frontend/src/ts/input/handlers/keydown.ts @@ -1,5 +1,4 @@ import { Config } from "../../config/store"; -import * as TestInput from "../../test/test-input"; import * as TestLogic from "../../test/test-logic"; import { getCharFromEvent } from "../../test/layout-emulator"; import * as Monkey from "../../test/monkey"; @@ -133,9 +132,6 @@ export async function onKeydown(event: KeyboardEvent): Promise { } const now = performance.now(); - if (!TestState.resultCalculating) { - TestInput.recordKeydownTime(now, event); - } logTestEvent("keydown", now, { code: getTestEventCode(event), diff --git a/frontend/src/ts/input/handlers/keyup.ts b/frontend/src/ts/input/handlers/keyup.ts index 2e04d12a7aba..5da7d8a86985 100644 --- a/frontend/src/ts/input/handlers/keyup.ts +++ b/frontend/src/ts/input/handlers/keyup.ts @@ -1,12 +1,10 @@ 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, diff --git a/frontend/src/ts/input/helpers/validation.ts b/frontend/src/ts/input/helpers/validation.ts index 5ab38fe05725..58b7580a4fa3 100644 --- a/frontend/src/ts/input/helpers/validation.ts +++ b/frontend/src/ts/input/helpers/validation.ts @@ -5,7 +5,7 @@ import { isSpace } from "../../utils/strings"; * Check if the input data is correct * @param options - Options object * @param options.data - Input data - * @param options.inputValue - Current input value (use TestInput.input.current, not input element value) + * @param options.inputValue - Current input value (use getCurrentInput(), not input element value) * @param options.targetWord - Target word * @param options.correctShiftUsed - Whether the correct shift state was used. Null means disabled */ @@ -47,7 +47,7 @@ export function isCharCorrect(options: { * as a "control character" (moving to the next word) * @param options - Options object * @param options.data - Input data - * @param options.inputValue - Current input value (use TestInput.input.current, not input element value) + * @param options.inputValue - Current input value (use getCurrentInput(), not input element value) * @param options.targetWord - Target word * @returns Boolean if data is space, null if not */ diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index 897d115e65c0..783abaaed183 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -1,5 +1,4 @@ import { Config } from "../../config/store"; -import * as TestInput from "../../test/test-input"; import * as TestUI from "../../test/test-ui"; import * as PaceCaret from "../../test/pace-caret"; import * as TestState from "../../test/test-state"; @@ -9,19 +8,19 @@ import { getActiveFunboxesWithFunction, isFunboxActiveWithProperty, } from "../../test/funbox/list"; -import * as TestStats from "../../test/test-stats"; -import * as Replay from "../../test/replay"; import * as Funbox from "../../test/funbox/funbox"; import { showLoaderBar, hideLoaderBar } from "../../states/loader-bar"; import { setInputElementValue } from "../input-element"; import { setAwaitingNextWord } from "../state"; import { DeleteInputType } from "./input-type"; +import { getInputForWord, getWordBurst } from "../../test/events/stats"; type GoToNextWordParams = { correctInsert: boolean; // this is used to tell test ui to update the word before moving to the next word (in case of a composition that ends with a space) isCompositionEnding: boolean; zenNewline?: boolean; + now: number; }; type GoToNextWordReturn = { @@ -33,6 +32,7 @@ export async function goToNextWord({ correctInsert, isCompositionEnding, zenNewline, + now, }: GoToNextWordParams): Promise { const ret = { increasedWordIndex: false, @@ -45,28 +45,18 @@ export async function goToNextWord({ isCompositionEnding || zenNewline === true, ); - if (correctInsert) { - Replay.addReplayEvent("submitCorrectWord"); - } else { - Replay.addReplayEvent("submitErrorWord"); - } - for (const fb of getActiveFunboxesWithFunction("handleSpace")) { fb.functions.handleSpace(); } //burst calculation and fail - const burst: number = TestStats.calculateBurst(); - TestInput.pushBurstToHistory(burst); + const burst = getWordBurst(TestState.activeWordIndex, now); ret.lastBurst = burst; PaceCaret.handleSpace(correctInsert, TestWords.words.getCurrentText()); Funbox.toggleScript(TestWords.words.getText(TestState.activeWordIndex + 1)); - TestInput.input.pushHistory(); - TestInput.corrected.pushHistory(); - const lastWord = TestState.activeWordIndex >= TestWords.words.length - 1; if (lastWord) { setAwaitingNextWord(true); @@ -87,8 +77,7 @@ export async function goToNextWord({ } setInputElementValue(""); - TestInput.input.syncWithInputElement(); - void TestUI.afterTestWordChange("forward"); + void TestUI.afterTestWordChange("forward", burst); return ret; } @@ -99,17 +88,13 @@ export function goToPreviousWord( ): void { if (TestState.activeWordIndex === 0) { setInputElementValue(""); - TestInput.input.syncWithInputElement(); return; } TestUI.beforeTestWordChange("back", null, forceUpdateActiveWordLetters); - Replay.addReplayEvent("backWord"); - - const word = TestInput.input.popHistory(); TestState.decreaseActiveWordIndex(); - TestInput.corrected.popHistory(); + const word = getInputForWord(TestState.activeWordIndex); Funbox.toggleScript(TestWords.words.getText(TestState.activeWordIndex)); @@ -126,7 +111,5 @@ export function goToPreviousWord( setInputElementValue(word); } } - TestInput.input.syncWithInputElement(); - void TestUI.afterTestWordChange("back"); } diff --git a/frontend/src/ts/input/listeners/composition.ts b/frontend/src/ts/input/listeners/composition.ts index cec96ead8fad..00c75128d13c 100644 --- a/frontend/src/ts/input/listeners/composition.ts +++ b/frontend/src/ts/input/listeners/composition.ts @@ -2,7 +2,6 @@ import { getInputElement } from "../input-element"; import * as CompositionState from "../../legacy-states/composition"; import * as TestState from "../../test/test-state"; import * as TestLogic from "../../test/test-logic"; -import * as TestInput from "../../test/test-input"; import { setLastInsertCompositionTextData } from "../state"; import * as CompositionDisplay from "../../elements/composition-display"; import { onInsertText } from "../handlers/insert-text"; @@ -25,9 +24,6 @@ inputEl.addEventListener("compositionstart", (event) => { if (!TestState.isActive) { TestLogic.startTest(now); } - if (TestInput.input.current.length === 0) { - TestInput.setBurstStart(now); - } logTestEvent("composition", now, { event: "start", diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index 7f697c2697d8..14bc1a7092f1 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -9,11 +9,11 @@ import { import * as TestUI from "../../test/test-ui"; import { onBeforeInsertText } from "../handlers/before-insert-text"; import { onBeforeDelete } from "../handlers/before-delete"; -import * as TestInput from "../../test/test-input"; import * as TestWords from "../../test/test-words"; import * as CompositionState from "../../legacy-states/composition"; import { activeWordIndex } from "../../test/test-state"; import { areAllTestWordsGenerated } from "../../test/test-logic"; +import { getCurrentInput } from "../../test/events/data"; const inputEl = getInputElement(); @@ -123,7 +123,7 @@ inputEl.addEventListener("input", async (event) => { ) { const allWordsTyped = activeWordIndex >= TestWords.words.length - 1; const inputPlusComposition = - TestInput.input.current + (CompositionState.getData() ?? ""); + getCurrentInput() + (CompositionState.getData() ?? ""); const inputPlusCompositionIsCorrect = TestWords.words.getCurrentText() === inputPlusComposition; diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index bbb380838266..187f60b998ac 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -1,5 +1,5 @@ import { Config } from "../config/store"; -import * as TestInput from "./test-input"; +import { getCurrentInput } from "./events/data"; import * as TestState from "../test/test-state"; import { configEvent } from "../events/config"; import { Caret } from "../elements/caret"; @@ -33,8 +33,7 @@ export function resetPosition(): void { export function updatePosition(noAnim = false): void { caret.goTo({ wordIndex: TestState.activeWordIndex, - letterIndex: - TestInput.input.current.length + CompositionState.getData().length, + letterIndex: getCurrentInput().length + CompositionState.getData().length, isLanguageRightToLeft: TestState.isLanguageRightToLeft, isDirectionReversed: TestState.isDirectionReversed, animate: Config.smoothCaret !== "off" && !noAnim, diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index ebda2b4c76ba..698071e14864 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -13,11 +13,10 @@ import { TimerEvent, TimerEventData, } from "./types"; -import { keysToTrack } from "./helpers"; -import { start } from "../test-stats"; +import { getInputFromDom, keysToTrack } from "./helpers"; import { Keycode } from "../../constants/keys"; import { roundTo2 } from "@monkeytype/util/numbers"; -import { resultCalculating } from "../test-state"; +import { activeWordIndex, resultCalculating } from "../test-state"; let keydownEvents: KeydownEvent[] = []; let keyupEvents: KeyupEvent[] = []; @@ -143,6 +142,10 @@ function invalidateCache(): void { cachedAllEvents = undefined; } +export function getCurrentInput(): string { + return getInputFromDom(getInputEventsForWord(activeWordIndex)); +} + export function cleanupData(): void { invalidateCache(); getAllTestEvents(); @@ -222,6 +225,9 @@ export function cleanupData(): void { export function getAllTestEvents(): TestEvent[] { if (cachedAllEvents !== undefined) return cachedAllEvents; + const startEventMs = + timerEvents.find((e) => e.data.event === "start")?.ms ?? 0; + // cachedAllEvents = testData300; // return cachedAllEvents; cachedAllEvents = [ @@ -237,7 +243,7 @@ export function getAllTestEvents(): TestEvent[] { (a.type === "timer" ? 1 : 0) - (b.type === "timer" ? 1 : 0), ) .map((event) => { - event.testMs = roundTo2(event.ms - start); + event.testMs = roundTo2(event.ms - startEventMs); return event; }); @@ -308,6 +314,18 @@ export function getPressedKeys(): Map< return pressedKeys; } +export function getInputEventsForWord(wordIndex: number): InputEvent[] { + const events = getAllTestEvents(); + const result: InputEvent[] = []; + for (const event of events) { + if (event.type !== "input") continue; + if (event.data.wordIndex === wordIndex) { + result.push(event); + } + } + return result; +} + export function getInputEventsPerWord( startMs?: number, testMsLimit?: number, @@ -327,18 +345,7 @@ export function getInputEventsPerWord( 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 wordIndex = event.data.wordIndex; const existing = eventsPerWordIndex.get(wordIndex) ?? []; existing.push(event); diff --git a/frontend/src/ts/test/events/helpers.ts b/frontend/src/ts/test/events/helpers.ts index 415b4916520b..b5d7186f05e4 100644 --- a/frontend/src/ts/test/events/helpers.ts +++ b/frontend/src/ts/test/events/helpers.ts @@ -93,25 +93,92 @@ export function getTestEventCode(event: KeyboardEvent): Keycode | "NoCode" { return event.code as Keycode; } -export function getSimulatedInput(events: InputEvent[]): string { - let simulatedInput = ""; +export function applyOp(input: string, event: InputEvent): string { + if (event.data.inputType === "insertText") { + if (event.data.inputStopped) return input; + return input + event.data.data; + } + if (event.data.inputType === "insertCompositionText") { + if (event.data.inputStopped) return input; + return input + event.data.data; + } + if (event.data.inputType === "deleteContentBackward") { + return input.slice(0, -1); + } + if (event.data.inputType === "deleteWordBackward") { + return input.replace(/(?:\S+\s*|\s+)$/, ""); + } + return input; +} +/** + * Derives input by applying each event's operation in order. Ignores the + * recorded inputValue field. Use for verification, tests, or fallback — + * not as source of truth. + */ +export function getInputFromEvents(events: InputEvent[]): string { + let input = ""; 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); + input = applyOp(input, event); + } + 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: InputEvent[]): string { + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i] as InputEvent; + if (event.data.inputValue !== undefined) { + let input = event.data.inputValue; + for (let j = i + 1; j < events.length; j++) { + input = applyOp(input, events[j] as InputEvent); + } + return input; } - if (event.data.inputType === "deleteWordBackward") { - simulatedInput = ""; + } + return getInputFromEvents(events); +} + +export type InputValueMismatch = { + index: number; + derived: string; + recorded: string; +}; + +/** + * Compares event-derived input against the recorded DOM snapshot at each + * event. Returns the indices where event-derivation disagreed with what the + * DOM captured. Useful for catching op-logic bugs or capture-timing bugs. + */ +export function findInputValueMismatches( + events: InputEvent[], +): InputValueMismatch[] { + const mismatches: InputValueMismatch[] = []; + let derived = ""; + + for (let i = 0; i < events.length; i++) { + const event = events[i] as InputEvent; + derived = applyOp(derived, event); + + if ( + event.data.inputValue !== undefined && + event.data.inputValue !== derived + ) { + mismatches.push({ + index: i, + derived, + recorded: event.data.inputValue, + }); } } - return simulatedInput; + return mismatches; } diff --git a/frontend/src/ts/test/replay.ts b/frontend/src/ts/test/events/replay-ui.ts similarity index 65% rename from frontend/src/ts/test/replay.ts rename to frontend/src/ts/test/events/replay-ui.ts index 05a19e649155..bb3afcce893a 100644 --- a/frontend/src/ts/test/replay.ts +++ b/frontend/src/ts/test/events/replay-ui.ts @@ -1,8 +1,11 @@ -import * as Sound from "../controllers/sound-controller"; -import * as TestInput from "./test-input"; -import * as Arrays from "../utils/arrays"; -import { qs, qsr } from "../utils/dom"; -import { Config } from "../config/store"; +import * as Sound from "../../controllers/sound-controller"; +import * as Arrays from "../../utils/arrays"; +import { qs, qsr } from "../../utils/dom"; +import { Config } from "../../config/store"; +import * as TestWords from "../test-words"; +import { getInputEvents } from "./data"; +import { getInputHistory, getInputForWord } from "./stats"; + type ReplayAction = | "correctLetter" | "incorrectLetter" @@ -19,21 +22,84 @@ type Replay = { let wordsList: string[] = []; let replayData: Replay[] = []; -let replayStartTime = 0; -let replayRecording = true; let wordPos = 0; let curPos = 0; let targetWordPos = 0; let targetCurPos = 0; let timeoutList: NodeJS.Timeout[] = []; let stopwatchList: NodeJS.Timeout[] = []; -const toggleButton = document.getElementById("playpauseReplayButton") - ?.children[0]; + +const toggleButton = (): Element | undefined => + document.getElementById("playpauseReplayButton")?.children[0]; const replayEl = qsr(".pageTest #resultReplay"); -function replayGetWordsList(wordsListFromScript: string[]): void { - wordsList = wordsListFromScript; +function getWordsList(): string[] { + if (Config.mode === "zen") return getInputHistory(); + return TestWords.words.list.slice(); +} + +function deriveReplayActions(): Replay[] { + const events = getInputEvents(); + const actions: Replay[] = []; + let prevWordIndex: number | undefined; + + for (const event of events) { + const wi = event.data.wordIndex; + + if (prevWordIndex !== undefined && wi !== prevWordIndex) { + if (wi > prevWordIndex) { + const typed = getInputForWord(prevWordIndex); + const target = + Config.mode === "zen" + ? typed + : TestWords.words.getText(prevWordIndex); + const correct = typed === target; + actions.push({ + action: correct ? "submitCorrectWord" : "submitErrorWord", + time: event.testMs, + }); + } else { + actions.push({ action: "backWord", time: event.testMs }); + } + } + + if ( + event.data.inputType === "insertText" || + event.data.inputType === "insertCompositionText" + ) { + if (event.data.inputStopped) { + prevWordIndex = wi; + continue; + } + actions.push({ + action: event.data.correct ? "correctLetter" : "incorrectLetter", + value: event.data.data, + time: event.testMs, + }); + } else if ( + event.data.inputType === "deleteContentBackward" || + event.data.inputType === "deleteWordBackward" + ) { + if (prevWordIndex !== undefined && wi < prevWordIndex) { + // word transition already emitted backWord above + } else { + const newCharIndex = + event.data.inputValue !== undefined + ? event.data.inputValue.length + : event.data.charIndex; + actions.push({ + action: "setLetterIndex", + value: newCharIndex, + time: event.testMs, + }); + } + } + + prevWordIndex = wi; + } + + return actions; } function initializeReplayPrompt(): void { @@ -44,7 +110,6 @@ function initializeReplayPrompt(): void { replayWordsElement.innerHTML = ""; let wordCount = 0; replayData.forEach((item) => { - //trim wordsList for timed tests if (item.action === "backWord") { wordCount--; } else if ( @@ -79,13 +144,11 @@ export function pauseReplay(): void { targetCurPos = curPos; targetWordPos = wordPos; - if (toggleButton === undefined) return; + const btn = toggleButton(); + if (btn === undefined) return; - toggleButton.className = "fas fa-play"; - (toggleButton.parentNode as Element)?.setAttribute( - "aria-label", - "Resume replay", - ); + btn.className = "fas fa-play"; + (btn.parentNode as Element)?.setAttribute("aria-label", "Resume replay"); } function playSound(error = false): void { @@ -113,7 +176,6 @@ function handleDisplayLogic(item: Replay, nosound = false): void { if (!nosound) playSound(true); let myElement; if (curPos >= activeWord.children.length) { - //if letter is an extra myElement = document.createElement("letter"); myElement?.classList.add("extra"); myElement.innerHTML = item.value?.toString() ?? ""; @@ -128,7 +190,6 @@ function handleDisplayLogic(item: Replay, nosound = false): void { ) { if (!nosound) playSound(); curPos = item.value; - // remove all letters from cursor to end of word for (const myElement of [...activeWord.children].slice(curPos)) { if (myElement?.classList.contains("extra")) { myElement.remove(); @@ -170,7 +231,6 @@ function loadOldReplay(): number { wordPos < targetWordPos || (wordPos === targetWordPos && curPos < targetCurPos) ) { - //quickly display everything up to the target handleDisplayLogic(item, true); startingIndex = i + 1; } @@ -182,7 +242,7 @@ function loadOldReplay(): number { throw new Error("Failed to load old replay: datatime is undefined"); } - const time = Math.floor(datatime / 1000); + const time = Math.max(0, Math.floor(datatime / 1000)); updateStatsString(time); return startingIndex; @@ -190,14 +250,13 @@ function loadOldReplay(): number { function toggleReplayDisplay(): void { if (replayEl.isHidden()) { + refreshReplayFromEvents(); initializeReplayPrompt(); loadOldReplay(); - //show void replayEl.slideDown(250); } else { - //hide if ( - (toggleButton?.parentNode as Element)?.getAttribute("aria-label") !== + (toggleButton()?.parentNode as Element)?.getAttribute("aria-label") !== "Start replay" ) { pauseReplay(); @@ -206,29 +265,15 @@ function toggleReplayDisplay(): void { } } -function startReplayRecording(): void { - replayData = []; - replayStartTime = performance.now(); - replayRecording = true; +function refreshReplayFromEvents(): void { + wordsList = getWordsList(); + replayData = deriveReplayActions(); targetCurPos = 0; targetWordPos = 0; } -function stopReplayRecording(): void { - replayRecording = false; -} - -function addReplayEvent(action: ReplayAction, value?: number | string): void { - if (!replayRecording) { - return; - } - - const timeDelta = performance.now() - replayStartTime; - replayData.push({ action: action, value: value, time: timeDelta }); -} - function updateStatsString(time: number): void { - const wpm = TestInput.wpmHistory[time - 1] ?? 0; + const wpm = 0; const statsString = `${wpm}wpm\t${time}s`; qs("#replayStats")?.setText(statsString); } @@ -237,13 +282,11 @@ function playReplay(): void { curPos = 0; wordPos = 0; - if (toggleButton === undefined) return; + const btn = toggleButton(); + if (btn === undefined) return; - toggleButton.className = "fas fa-pause"; - (toggleButton.parentNode as Element)?.setAttribute( - "aria-label", - "Pause replay", - ); + btn.className = "fas fa-pause"; + (btn.parentNode as Element)?.setAttribute("aria-label", "Pause replay"); initializeReplayPrompt(); const startingIndex = loadOldReplay(); const lastTime = replayData[startingIndex]?.time; @@ -252,7 +295,7 @@ function playReplay(): void { throw new Error("Failed to play replay: lastTime is undefined"); } - let swTime = Math.round(lastTime / 1000); //starting time + let swTime = Math.round(lastTime / 1000); const swEndTime = Math.round( (Arrays.lastElementFromArray(replayData) as Replay).time / 1000, ); @@ -279,37 +322,26 @@ function playReplay(): void { timeoutList.push( setTimeout( () => { - //after the replay has finished, this will run targetCurPos = 0; targetWordPos = 0; - toggleButton.className = "fas fa-play"; - (toggleButton.parentNode as Element).setAttribute( - "aria-label", - "Start replay", - ); + btn.className = "fas fa-play"; + (btn.parentNode as Element).setAttribute("aria-label", "Start replay"); }, (Arrays.lastElementFromArray(replayData) as Replay).time - lastTime, ), ); } -function getReplayExport(): string { - return JSON.stringify({ - replayData: replayData, - wordsList: wordsList, - }); -} - qs(".pageTest #playpauseReplayButton")?.on("click", () => { - if (toggleButton?.className === "fas fa-play") { + const btn = toggleButton(); + if (btn?.className === "fas fa-play") { playReplay(); - } else if (toggleButton?.className === "fas fa-pause") { + } else if (btn?.className === "fas fa-pause") { pauseReplay(); } }); qs("#replayWords")?.onChild("click", "letter", (event) => { - //allows user to click on the place they want to start their replay at pauseReplay(); const replayWords = qs("#replayWords"); @@ -329,11 +361,3 @@ qs("#replayWords")?.onChild("click", "letter", (event) => { qs(".pageTest")?.onChild("click", "#watchReplayButton", () => { toggleReplayDisplay(); }); - -export { - startReplayRecording, - stopReplayRecording, - addReplayEvent, - replayGetWordsList, - getReplayExport, -}; diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 3f8ae474ba7b..189e2aaf623d 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -1,6 +1,7 @@ import { getAllTestEvents, getInputEvents, + getInputEventsForWord, getInputEventsPerWord, getPressedKeys, logTestEvent, @@ -8,7 +9,7 @@ import { 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 { getInputFromDom } from "./helpers"; import { activeWordIndex, bailedOut } from "../test-state"; import { calculateWpm } from "../../utils/numbers"; import { mean, roundTo2 } from "@monkeytype/util/numbers"; @@ -183,6 +184,28 @@ export function getRawPerSecond(): number[] { }); } +export function getCurrentTestDurationMs(now: number): number { + const events = getAllTestEvents(); + + let start: number | undefined; + + for (const event of events) { + if ( + start === undefined && + event.type === "timer" && + event.data.event === "start" + ) { + start = event.ms; + } + } + + if (start === undefined) { + return 0; + } + + return now - start; +} + export function getTestDurationMs(): number { const events = getAllTestEvents(); @@ -224,6 +247,36 @@ export function getTestDurationMs(): number { return end; } +export function getDateBasedTestDurationMs(): number { + const events = getAllTestEvents(); + + let start: number | undefined; + let end: number | undefined; + + for (const event of events) { + if ( + start === undefined && + event.type === "timer" && + event.data.event === "start" + ) { + start = event.data.date; + } + if ( + end === undefined && + event.type === "timer" && + event.data.event === "end" + ) { + end = event.data.date; + } + } + + if (start === undefined || end === undefined) { + return 0; + } + + return end - start; +} + function getTargetWord( wordIndex: number, simulatedInput: string, @@ -243,12 +296,103 @@ function getTargetWord( } } -export function getChars(): CharCounts { +export function getCurrentWpmAndRaw(now?: number): { + wpm: number; + raw: number; +} { + const chars = getChars(true); + const currentTestDurationMs = getCurrentTestDurationMs( + now ?? performance.now(), + ); + const wpm = Math.round( + calculateWpm(chars.correctWord, currentTestDurationMs / 1000), + ); + const raw = Math.round( + calculateWpm( + chars.allCorrect + chars.extra + chars.incorrect, + currentTestDurationMs / 1000, + ), + ); + return { wpm, raw }; +} + +export function getCurrentAccuracy(): number { + const events = getAllTestEvents(); + + let correct = 0; + let total = 0; + + for (const event of events) { + if (event.type === "input" && "correct" in event.data) { + total++; + if (event.data.correct) { + correct++; + } + } + } + + return total === 0 ? 100 : (correct / total) * 100; +} + +//todo: composition start must be the start time for burst calculation +function computeBurst(events: InputEvent[], now?: number): number { + const input = getInputFromDom(events); + + let inputLength = input.length; + if (!input.endsWith(" ") && !input.endsWith("\n")) { + inputLength += 1; // account for trigger char (space/newline) on word submit + } + + let firstKeypressTime: number | undefined; + let lastKeypressTime: number | undefined; + + for (const event of events) { + if (event.type === "input" && event.data.inputType === "insertText") { + if (event.data.charIndex === 0) { + firstKeypressTime = event.ms; + } + if (event.data.data === " ") { + lastKeypressTime = event.ms; + } + } + } + + if (firstKeypressTime === undefined || input.length === 0) { + return 0; + } + + if (lastKeypressTime !== undefined && lastKeypressTime < firstKeypressTime) { + lastKeypressTime = undefined; + } + + const endTime = lastKeypressTime ?? now ?? performance.now(); + + const durationSeconds = (endTime - firstKeypressTime) / 1000; + if (durationSeconds <= 0) return Infinity; + + return Math.round(calculateWpm(inputLength, durationSeconds)); +} + +export function getWordBurst(wordIndex: number, now?: number): number { + const events = getInputEventsForWord(wordIndex); + return computeBurst(events, now); +} + +export function getBurstHistory(): number[] { + const eventsPerWord = getInputEventsPerWord(); + const burstHistory: number[] = []; + for (let i = 0; i < TestWords.words.length; i++) { + burstHistory.push(computeBurst(eventsPerWord.get(i) ?? [])); + } + return burstHistory; +} + +export function getChars(countPartialLastWord = false): CharCounts { const eventsPerWordIndex = getInputEventsPerWord(); const isTimedTest = Config.mode === "time" || (Config.mode === "custom" && CustomText.getLimit().mode === "time"); - const shouldCountPartialLastWord = isTimedTest; + const shouldCountPartialLastWord = isTimedTest || countPartialLastWord; let allCorrect = 0; let correctWord = 0; @@ -259,7 +403,7 @@ export function getChars(): CharCounts { for (const [wordIndex, events] of eventsPerWordIndex.entries()) { const lastWord = wordIndex === activeWordIndex; - let simulatedInput = getSimulatedInput(events); + let simulatedInput = getInputFromDom(events); if (lastWord) { //remove trailing space for last word @@ -295,6 +439,25 @@ export function getChars(): CharCounts { }; } +export function getInputForWord(wordIndex: number): string { + const events = getInputEventsForWord(wordIndex); + return getInputFromDom(events).trimEnd(); +} + +export function getInputHistory(): string[] { + console.log("getting input history"); + console.trace("getting input"); + const eventsPerWordIndex = getInputEventsPerWord(); + const history: string[] = []; + + for (const events of eventsPerWordIndex.values()) { + const simulatedInput = getInputFromDom(events); + history.push(simulatedInput.trimEnd()); + } + + return history; +} + export function getAccuracy(): { correct: number; incorrect: number; @@ -375,6 +538,54 @@ export function getKeypressOverlap(): number { return roundTo2(overlap); } +export function getIncorrectWordIndexesForSecond(second: number): number[] { + const events = getAllTestEvents(); + const boundaries = getTimerBoundaries(events); + + const boundary = boundaries[second]; + if (boundary === undefined) return []; + + const prevBoundary = second > 0 ? boundaries[second - 1] : undefined; + const wordIndexes = new Set(); + + for (const event of events) { + if (prevBoundary !== undefined && event.testMs <= prevBoundary) continue; + if (event.testMs > boundary) break; + + if ( + event.type === "input" && + event.data.inputType === "insertText" && + !event.data.correct + ) { + wordIndexes.add(event.data.wordIndex); + } + } + + return [...wordIndexes]; +} + +export function getWordIndexesForSecond(second: number): number[] { + const events = getAllTestEvents(); + const boundaries = getTimerBoundaries(events); + + const boundary = boundaries[second]; + if (boundary === undefined) return []; + + const prevBoundary = second > 0 ? boundaries[second - 1] : undefined; + const wordIndexes = new Set(); + + for (const event of events) { + if (prevBoundary !== undefined && event.testMs <= prevBoundary) continue; + if (event.testMs > boundary) break; + + if (event.type === "input" && event.data.inputType === "insertText") { + wordIndexes.add(event.data.wordIndex); + } + } + + return [...wordIndexes]; +} + export function getErrorCountHistory(): number[] { const { counts } = countPerInterval( (e) => @@ -400,7 +611,7 @@ export function getWpmHistory(): number[] { >(); let maxWordIndex = 0; for (const [k, wordEvents] of eventsPerWord) { - const input = getSimulatedInput(wordEvents); + const input = getInputFromDom(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 @@ -443,6 +654,62 @@ export function getWpmHistory(): number[] { return wpmHistory; } +export function getRawHistory(): 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 = getInputFromDom(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 ? "" : " "); + + const count = countChars(trimmed, targetWord, lastWord, true); + + totalCorrect += count.allCorrect + count.extra + count.incorrect; + } + + 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", @@ -506,6 +773,69 @@ export function forceReleaseAllKeys(): void { } } +export function getMissedWords(): Record { + const events = getAllTestEvents(); + + const missedWords: Record = {}; + + for (const event of events) { + if ( + event.type === "input" && + event.data.inputType === "insertText" && + !event.data.correct + ) { + const word = TestWords.words.getText(event.data.wordIndex); + if (missedWords[word] === undefined) { + missedWords[word] = 1; + } else { + missedWords[word]++; + } + } + } + + return missedWords; +} + +export function getCorrectedWords(): string[] { + const ev = getInputEventsPerWord(); + const correctedWords: string[] = []; + + for (const [, events] of ev.entries()) { + const correctedChars: string[] = []; + const currentChars: string[] = []; + let cursorPos = 0; + + for (const event of events) { + if ( + event.data.inputType === "insertText" || + event.data.inputType === "insertCompositionText" + ) { + if (event.data.inputStopped || event.data.data === " ") continue; + currentChars[cursorPos] = event.data.data; + cursorPos++; + } else if (event.data.inputType === "deleteContentBackward") { + if (cursorPos > 0) { + cursorPos--; + correctedChars[cursorPos] = currentChars[cursorPos] ?? ""; + } + } else if (event.data.inputType === "deleteWordBackward") { + while (cursorPos > 0) { + cursorPos--; + correctedChars[cursorPos] = currentChars[cursorPos] ?? ""; + } + } + } + + const result: string[] = []; + for (let i = 0; i < currentChars.length; i++) { + result.push(correctedChars[i] ?? currentChars[i] ?? ""); + } + correctedWords.push(result.join("")); + } + + return correctedWords; +} + export const __testing = { getTimerBoundaries, }; diff --git a/frontend/src/ts/test/events/types.ts b/frontend/src/ts/test/events/types.ts index 94712b8dac63..9b62d6b26e85 100644 --- a/frontend/src/ts/test/events/types.ts +++ b/frontend/src/ts/test/events/types.ts @@ -65,25 +65,28 @@ export type TimerEventData = | { event: "start" | "end"; timer: number; + date: number; }; export type InputEvent = EventProps<"input", InputEventData>; -export type InputEventData = { +type BaseInputEventData = { charIndex: number; wordIndex: number; -} & ( - | { + inputValue?: string; +}; + +export type InputEventData = + | (BaseInputEventData & { inputType: InsertInputType; data: string; correct: boolean; isCompositionEnding: boolean; inputStopped: boolean; - } - | { + }) + | (BaseInputEventData & { inputType: DeleteInputType; - } -); + }); export type CompositionTestEvent = EventProps< "composition", diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 78377be3a229..cc7f696e3a26 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -14,7 +14,7 @@ import { } from "../../states/notifications"; import * as DDR from "../../utils/ddr"; import * as TestWords from "../test-words"; -import * as TestInput from "../test-input"; +import { getCurrentInput } from "../events/data"; import * as LayoutfluidFunboxTimer from "./layoutfluid-funbox-timer"; import { highlight } from "../../events/keymap"; import * as MemoryTimer from "./memory-funbox-timer"; @@ -28,6 +28,7 @@ import { WordGenError } from "../../utils/word-gen-error"; import { FunboxName, KeymapLayout, Layout } from "@monkeytype/schemas/configs"; import { Language, LanguageObject } from "@monkeytype/schemas/languages"; import { qs } from "../../utils/dom"; +import { getInputForWord } from "../events/stats"; export type FunboxFunctions = { getWord?: (wordset?: Wordset, wordIndex?: number) => string; @@ -52,17 +53,18 @@ export type FunboxFunctions = { }; async function readAheadHandleKeydown(event: KeyboardEvent): Promise { - const inputCurrentChar = (TestInput.input.current ?? "").slice(-1); + const currentInput = getCurrentInput(); + const inputCurrentChar = currentInput.slice(-1); const wordCurrentChar = TestWords.words .getCurrentText() - .slice(TestInput.input.current.length - 1, TestInput.input.current.length); + .slice(currentInput.length - 1, currentInput.length); const isCorrect = inputCurrentChar === wordCurrentChar; if ( event.key === "Backspace" && !isCorrect && - (TestInput.input.current !== "" || - TestInput.input.getHistory(TestState.activeWordIndex - 1) !== + (currentInput !== "" || + getInputForWord(TestState.activeWordIndex - 1) !== TestWords.words.getText(TestState.activeWordIndex - 1) || Config.freedomMode) ) { @@ -425,7 +427,7 @@ const list: Partial> = { const outOf: number = TestWords.words.length; const wordsPerLayout = Math.floor(outOf / layouts.length); const index = Math.floor( - (TestInput.input.getHistory().length + 1) / wordsPerLayout, + (TestState.activeWordIndex + 1) / wordsPerLayout, ); const mod = wordsPerLayout - ((TestState.activeWordIndex + 1) % wordsPerLayout); @@ -452,9 +454,7 @@ const list: Partial> = { } setTimeout(() => { highlight( - TestWords.words - .getCurrentText() - .charAt(TestInput.input.current.length), + TestWords.words.getCurrentText().charAt(getCurrentInput().length), ); }, 1); } diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index b1ee407c3dfd..35d57cf57a4b 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -4,11 +4,15 @@ import { showNoticeNotification } from "../states/notifications"; import { Config } from "../config/store"; import { setConfig } from "../config/setters"; import * as CustomText from "./custom-text"; -import * as TestInput from "./test-input"; import { configEvent } from "../events/config"; import { setCustomTextName } from "../legacy-states/custom-text-name"; import { Mode } from "@monkeytype/schemas/shared"; import { CustomTextSettings } from "@monkeytype/schemas/results"; +import { + getBurstHistory, + getInputHistory, + getMissedWords, +} from "./events/stats"; type Before = { mode: Mode | null; @@ -37,11 +41,13 @@ export function init( limit = 10; } + const missedWords = getMissedWords(); + // missed word, previous word, count let sortableMissedWords: [string, number][] = []; if (missed === "words") { - Object.keys(TestInput.missedWords).forEach((missedWord) => { - const missedWordCount = TestInput.missedWords[missedWord]; + Object.keys(missedWords).forEach((missedWord) => { + const missedWordCount = missedWords[missedWord]; if (missedWordCount !== undefined) { sortableMissedWords.push([missedWord, missedWordCount]); } @@ -56,7 +62,7 @@ export function init( if (missed === "biwords") { for (let i = 0; i < TestWords.words.length; i++) { const missedWord = TestWords.words.getText(i); - const missedWordCount = TestInput.missedWords[missedWord]; + const missedWordCount = missedWords[missedWord]; if (missedWordCount !== undefined) { if (i === 0) { sortableMissedBiwords.push([missedWord, "", missedWordCount]); @@ -88,12 +94,11 @@ export function init( if (slow) { const typedWords = TestWords.words .getText() - .slice(0, TestInput.input.getHistory().length - 1); + .slice(0, getInputHistory().length - 1); + + const burstHistory = getBurstHistory(); - sortableSlowWords = typedWords.map((e, i) => [ - e, - TestInput.burstHistory[i] ?? 0, - ]); + sortableSlowWords = typedWords.map((e, i) => [e, burstHistory[i] ?? 0]); sortableSlowWords.sort((a, b) => { return a[1] - b[1]; }); diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 65e7dda1e126..743d79da11b7 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -26,8 +26,6 @@ import * as Numbers from "@monkeytype/util/numbers"; import * as Arrays from "../utils/arrays"; import { get as getTypingSpeedUnit } from "../utils/typing-speed-units"; import * as PbCrown from "./pb-crown"; -import * as TestInput from "./test-input"; -import * as TestStats from "./test-stats"; import * as TestUI from "./test-ui"; import * as TodayTracker from "./today-tracker"; import { configEvent } from "../events/config"; @@ -62,6 +60,7 @@ import { currentQuote } from "./test-words"; import { qs, qsa } from "../utils/dom"; import { getTheme } from "../states/theme"; import { isTestInvalid } from "../states/test"; +import { getAccuracy, getRawHistory } from "./events/stats"; let result: CompletedEvent; let minChartVal: number; @@ -110,12 +109,12 @@ async function updateChartData(): Promise { let labels = []; - for (let i = 1; i <= TestInput.wpmHistory.length; i++) { - if (TestStats.lastSecondNotRound && i === TestInput.wpmHistory.length) { - labels.push(Numbers.roundTo2(result.testDuration).toString()); - } else { - labels.push(i.toString()); - } + for (let i = 1; i <= Math.floor(result.testDuration); i++) { + labels.push(i.toString()); + } + + if (result.testDuration % 1 >= 0.5) { + labels.push(Numbers.roundTo2(result.testDuration).toString()); } const chartData1 = [ @@ -124,11 +123,7 @@ async function updateChartData(): Promise { ), ]; - const chartData2 = [ - ...TestInput.rawHistory.map((a) => - Numbers.roundTo2(typingSpeedUnit.fromWpm(a)), - ), - ]; + const chartData2: number[] = getRawHistory(); const valueWindow = Math.max(...result.chartData.burst) * 0.25; let smoothedBurst = Arrays.smoothWithValueWindow( @@ -141,16 +136,6 @@ async function updateChartData(): Promise { ...smoothedBurst.map((a) => Numbers.roundTo2(typingSpeedUnit.fromWpm(a))), ]; - if ( - Config.mode !== "time" && - TestStats.lastSecondNotRound && - result.testDuration % 1 < 0.5 - ) { - labels.pop(); - chartData1.pop(); - chartData2.pop(); - } - const subcolor = getTheme().sub; if (Config.funbox.length > 0) { @@ -358,6 +343,7 @@ function updateWpmAndAcc(): void { result.acc === 100 ? "100%" : Format.accuracy(result.acc), ); + const acc = getAccuracy(); if (Config.alwaysShowDecimalPlaces) { if (Config.typingSpeedUnit !== "wpm") { qs("#result .stats .wpm .bottom")?.setAttribute( @@ -382,7 +368,7 @@ function updateWpmAndAcc(): void { qs("#result .stats .acc .bottom")?.setAttribute( "aria-label", - `${TestInput.accuracy.correct} correct\n${TestInput.accuracy.incorrect} incorrect`, + `${acc.correct} correct\n${acc.incorrect} incorrect`, ); } else { //not showing decimal places @@ -408,9 +394,7 @@ function updateWpmAndAcc(): void { result.acc === 100 ? "100%" : Format.percentage(result.acc, { showDecimalPlaces: true }) - }\n${TestInput.accuracy.correct} correct\n${ - TestInput.accuracy.incorrect - } incorrect`, + }\n${acc.correct} correct\n${acc.incorrect} incorrect`, ) ?.setAttribute("data-balloon-break", ""); } diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts deleted file mode 100644 index e74f8ba05b07..000000000000 --- a/frontend/src/ts/test/test-input.ts +++ /dev/null @@ -1,559 +0,0 @@ -import { lastElementFromArray } from "../utils/arrays"; -import { mean, roundTo2 } from "@monkeytype/util/numbers"; -import * as TestState from "./test-state"; -import { Config } from "../config/store"; -import { getInputElementValue } from "../input/input-element"; - -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 -]); - -type KeypressTimings = { - spacing: { - first: number; - last: number; - array: number[]; - }; - duration: { - array: number[]; - }; -}; - -type Keydata = { - timestamp: number; - index: number; -}; - -type ErrorHistoryObject = { - count: number; - words: number[]; -}; - -class Input { - current: string; - private history: string[]; - koreanStatus: boolean; - constructor() { - this.current = ""; - this.history = []; - this.koreanStatus = false; - } - - reset(): void { - this.current = ""; - this.history = []; - } - - resetHistory(): void { - this.history = []; - } - - setKoreanStatus(val: boolean): void { - this.koreanStatus = val; - } - - getKoreanStatus(): boolean { - return this.koreanStatus; - } - - pushHistory(): void { - this.history.push(this.current); - this.current = ""; - } - - popHistory(): string { - const ret = this.history.pop() ?? ""; - return ret; - } - - get(index: number): string | undefined { - return this.history[index]; - } - - getHistory(): string[]; - getHistory(i: number): string | undefined; - getHistory(i?: number): unknown { - if (i === undefined) { - return this.history; - } else { - return this.history[i]; - } - } - - getHistoryLast(): string | undefined { - return lastElementFromArray(this.history); - } - - syncWithInputElement(): void { - this.current = getInputElementValue().inputValue; - } -} - -class Corrected { - current: string; - private history: string[]; - constructor() { - this.current = ""; - this.history = []; - } - - reset(): void { - this.history = []; - this.current = ""; - } - - update(char: string, correct: boolean): void { - if (this.current === "") { - this.current += input.current; - } else { - const currCorrectedTestInputLength = this.current.length; - - const charIndex = input.current.length - 1; - - if (charIndex >= currCorrectedTestInputLength) { - this.current += char; - } else if (!correct) { - this.current = - this.current.substring(0, charIndex) + - char + - this.current.substring(charIndex + 1); - } - } - } - - getHistory(i: number): string | undefined { - return this.history[i]; - } - - popHistory(): string { - const popped = this.history.pop() ?? ""; - this.current = popped; - return popped; - } - - pushHistory(): void { - this.history.push(this.current); - this.current = ""; - } -} - -let keyDownData: Record = {}; - -export const input = new Input(); -export const corrected = new Corrected(); - -export let keypressCountHistory: number[] = []; -let currentKeypressCount = 0; -export let currentBurstStart = 0; -type MissedWordsType = Record; -// We're using Object.create(null) to make sure that __proto__ won't have any special meaning when it's used to index the missedWords object (so if a user mistypes the word __proto__ it will appear in the practise words test) -export let missedWords: MissedWordsType = Object.create( - null, -) as MissedWordsType; -export let accuracy = { - correct: 0, - incorrect: 0, -}; -export let keypressTimings: KeypressTimings = { - spacing: { - first: -1, - last: -1, - array: [], - }, - duration: { - array: [], - }, -}; -export let keyOverlap = { - total: 0, - lastStartTime: -1, -}; -export let wpmHistory: number[] = []; -export let rawHistory: number[] = []; -export let burstHistory: number[] = []; -export let errorHistory: ErrorHistoryObject[] = []; -let currentErrorHistory: ErrorHistoryObject = { - count: 0, - words: [], -}; - -export let afkHistory: boolean[] = []; -let currentAfk = true; - -export function incrementKeypressCount(): void { - currentKeypressCount++; -} - -export function setCurrentNotAfk(): void { - currentAfk = false; -} - -export function incrementKeypressErrors(): void { - currentErrorHistory.count++; -} - -export function pushKeypressWord(wordIndex: number): void { - currentErrorHistory.words.push(wordIndex); -} - -export function setBurstStart(time: number): void { - currentBurstStart = time; -} - -export function pushKeypressesToHistory(): void { - keypressCountHistory.push(currentKeypressCount); - currentKeypressCount = 0; -} - -export function pushAfkToHistory(): void { - afkHistory.push(currentAfk); - currentAfk = true; -} - -export function pushErrorToHistory(): void { - errorHistory.push(currentErrorHistory); - currentErrorHistory = { - count: 0, - words: [], - }; -} - -export function incrementAccuracy(correctincorrect: boolean): void { - if (correctincorrect) { - accuracy.correct++; - } else { - accuracy.incorrect++; - } -} - -export function forceKeyup(now: number): void { - //using mean here because for words mode, the last keypress ends the test. - //if we then force keyup on that last keypress, it will record a duration of 0 - //skewing the average and standard deviation - - const indexesToRemove = new Set( - Object.values(keyDownData).map((data) => data.index), - ); - - 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 - // or we should use a magic number - avg = 80; - } else { - avg = roundTo2(mean(keypressDurations)); - } - - const orderedKeys = Object.entries(keyDownData).sort( - (a, b) => a[1].timestamp - b[1].timestamp, - ); - - for (const [key, { index }] of orderedKeys) { - keypressTimings.duration.array[index] = avg; - - if (key === "NoCode") { - noCodeIndex--; - } - - // oxlint-disable-next-line no-dynamic-delete - delete keyDownData[key]; - - updateOverlap(now); - } -} - -function getEventCode(event: KeyboardEvent): string { - 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; -} - -let noCodeIndex = 0; -export function recordKeyupTime(now: number, event: KeyboardEvent): void { - if (event.repeat) { - console.log( - "Keyup not recorded - repeat", - event.key, - event.code, - //ignore for logging - // oxlint-disable-next-line no-deprecated - event.which, - ); - return; - } - - let key = getEventCode(event); - - if (!keysToTrack.has(key)) return; - - if (key === "NoCode") { - noCodeIndex--; - key = `NoCode${noCodeIndex}`; - } - - const keyDownDataForKey = keyDownData[key]; - - if (keyDownDataForKey === undefined) return; - - const diff = Math.abs(keyDownDataForKey.timestamp - now); - keypressTimings.duration.array[keyDownDataForKey.index] = diff; - - console.debug("Keyup recorded", key, diff); - // oxlint-disable-next-line no-dynamic-delete - delete keyDownData[key]; - - updateOverlap(now); -} - -export function recordKeydownTime(now: number, event: KeyboardEvent): void { - if (event.repeat) { - console.log( - "Keydown not recorded - repeat", - event.key, - event.code, - //ignore for logging - // oxlint-disable-next-line no-deprecated - event.which, - ); - return; - } - - let key = getEventCode(event); - - if (!keysToTrack.has(key)) { - console.debug("Keydown not recorded - not tracked", key); - return; - } - - if (keyDownData[key] !== undefined) { - console.debug("Key already down", key); - return; - } - - if (key === "NoCode") { - key = `NoCode${noCodeIndex}`; - noCodeIndex++; - } - - keyDownData[key] = { - timestamp: now, - index: keypressTimings.duration.array.length, - }; - keypressTimings.duration.array.push(0); - - updateOverlap(keyDownData[key]?.timestamp as number); - - if (keypressTimings.spacing.last !== -1) { - const diff = Math.abs(now - keypressTimings.spacing.last); - keypressTimings.spacing.array.push(roundTo2(diff)); - console.debug("Keydown recorded", key, diff); - } - keypressTimings.spacing.last = now; - if (keypressTimings.spacing.first === -1) { - keypressTimings.spacing.first = now; - console.debug("First keydown recorded", key, now); - } -} - -function updateOverlap(now: number): void { - const keys = Object.keys(keyDownData); - if (keys.length > 1) { - if (keyOverlap.lastStartTime === -1) { - keyOverlap.lastStartTime = now; - } - } else { - if (keyOverlap.lastStartTime !== -1) { - keyOverlap.total += now - keyOverlap.lastStartTime; - keyOverlap.lastStartTime = -1; - } - } -} - -export function carryoverFirstKeypress(): void { - // Because keydown triggers before input, we need to grab the first keypress data here and carry it over - - // Take the key with the largest index - const lastKey = Object.keys(keyDownData).reduce((a, b) => { - const aIndex = keyDownData[a]?.index; - const bIndex = keyDownData[b]?.index; - if (aIndex === undefined) return b; - if (bIndex === undefined) return a; - return aIndex > bIndex ? a : b; - }, ""); - - // Get the data - const lastKeyData = keyDownData[lastKey]; - - // Carry over - if (lastKeyData !== undefined) { - keypressTimings = { - spacing: { - first: lastKeyData.timestamp, - last: lastKeyData.timestamp, - array: [], - }, - duration: { - array: [0], - }, - }; - keyDownData[lastKey] = { - timestamp: lastKeyData.timestamp, - // Make sure to set it to the first index - index: 0, - }; - } -} - -function resetKeypressTimings(): void { - keypressTimings = { - spacing: { - first: -1, - last: -1, - array: [], - }, - duration: { - array: [], - }, - }; - keyOverlap = { - total: 0, - lastStartTime: -1, - }; - keyDownData = {}; - noCodeIndex = 0; - - console.debug("Keypress timings reset"); -} - -export function pushMissedWord(word: string): void { - if (!Object.keys(missedWords).includes(word)) { - missedWords[word] = 1; - } else { - (missedWords[word] as number) += 1; - } -} - -export function pushToWpmHistory(wpm: number): void { - wpmHistory.push(wpm); -} - -export function pushToRawHistory(raw: number): void { - rawHistory.push(raw); -} - -export function pushBurstToHistory(speed: number): void { - if (burstHistory[TestState.activeWordIndex] === undefined) { - burstHistory.push(speed); - } else { - //repeated word - override - burstHistory[TestState.activeWordIndex] = speed; - } -} - -export function restart(): void { - wpmHistory = []; - rawHistory = []; - burstHistory = []; - keypressCountHistory = []; - currentKeypressCount = 0; - afkHistory = []; - currentAfk = true; - errorHistory = []; - currentErrorHistory = { - count: 0, - words: [], - }; - currentBurstStart = 0; - missedWords = Object.create(null) as MissedWordsType; - accuracy = { - correct: 0, - incorrect: 0, - }; - - resetKeypressTimings(); -} diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 337e19eb11c9..3996e6266ae2 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -12,7 +12,6 @@ import { } from "../states/notifications"; import * as CustomText from "./custom-text"; import * as CustomTextState from "../legacy-states/custom-text-name"; -import * as TestStats from "./test-stats"; import * as PractiseWords from "./practise-words"; import * as ShiftTracker from "./shift-tracker"; import * as AltTracker from "./alt-tracker"; @@ -21,7 +20,7 @@ import * as PaceCaret from "./pace-caret"; import * as Caret from "./caret"; import * as TestTimer from "./test-timer"; import * as DB from "../db"; -import * as Replay from "./replay"; +import * as Replay from "./events/replay-ui"; import { __nonReactive } from "../collections/tags"; import * as TodayTracker from "./today-tracker"; import * as ChallengeContoller from "../controllers/challenge-controller"; @@ -41,7 +40,6 @@ import { setWordsHaveTab, } from "../states/test"; import { restartTestEvent } from "../events/test"; -import * as TestInput from "./test-input"; import * as TestWords from "./test-words"; import * as WordsGenerator from "./words-generator"; import * as TestState from "./test-state"; @@ -104,7 +102,10 @@ import { getWpmHistory, getAfkDuration, forceReleaseAllKeys, - getKeypressesPerSecond, + getCurrentAccuracy, + getCurrentTestDurationMs, + getDateBasedTestDurationMs, + getInputHistory, } from "./events/stats"; import { calculateWpm } from "../utils/numbers"; @@ -175,9 +176,6 @@ export function startTest(now: number): boolean { } TestState.setActive(true); - Replay.startReplayRecording(); - Replay.replayGetWordsList(TestWords.words.list); - TestInput.carryoverFirstKeypress(); Time.set(0); TestTimer.clear(); @@ -194,8 +192,7 @@ export function startTest(now: number): boolean { } } catch (e) {} //use a recursive self-adjusting timer to avoid time drift - TestStats.setStart(now); - void TestTimer.start(); + void TestTimer.start(now); TestUI.onTestStart(); return true; } @@ -275,14 +272,11 @@ export function restart(options = {} as RestartOptions): void { } if (Config.resultSaving) { - TestInput.pushKeypressesToHistory(); - TestInput.pushErrorToHistory(); - TestInput.pushAfkToHistory(); - const testSeconds = TestStats.calculateTestSeconds(performance.now()); - const afkseconds = TestStats.calculateAfkSeconds(testSeconds); + const testSeconds = getCurrentTestDurationMs(performance.now()) / 1000; + const afkseconds = getAfkDuration(); let tt = Numbers.roundTo2(testSeconds - afkseconds); if (tt < 0) tt = 0; - const acc = Numbers.roundTo2(TestStats.calculateAccuracy()); + const acc = Numbers.roundTo2(getCurrentAccuracy()); pushIncompleteTest({ acc, seconds: tt }); } } @@ -326,19 +320,15 @@ export function restart(options = {} as RestartOptions): void { resetTestEvents(); TestTimer.clear(); setIsTestInvalid(false); - TestStats.restart(); - TestInput.restart(); - TestInput.corrected.reset(); ShiftTracker.reset(); AltTracker.reset(); Caret.hide(); TestState.setActive(false); - Replay.stopReplayRecording(); Replay.pauseReplay(); TestState.setBailedOut(false); Caret.resetPosition(); PaceCaret.reset(); - TestInput.input.setKoreanStatus(false); + TestState.setKoreanStatus(false); clearQuoteStats(); CompositionState.setComposing(false); CompositionState.setData(""); @@ -435,11 +425,8 @@ async function init(): Promise { return false; } - Replay.stopReplayRecording(); TestWords.words.reset(); TestState.setActiveWordIndex(0); - TestInput.input.resetHistory(); - TestInput.input.current = ""; showLoaderBar(); const { data: language, error } = await tryCatch( @@ -599,7 +586,7 @@ async function init(): Promise { /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/g, ) ) { - TestInput.input.setKoreanStatus(true); + TestState.setKoreanStatus(true); } for (let i = 0; i < generatedWords.length; i++) { @@ -660,7 +647,7 @@ export function areAllTestWordsGenerated(): boolean { //add word during the test export async function addWord(): Promise { if (Config.mode === "zen") { - TestUI.appendEmptyWordElement(); + TestUI.appendEmptyWordElement(TestState.activeWordIndex + 1); return; } @@ -674,7 +661,7 @@ export async function addWord(): Promise { const toPushCount = funboxToPush?.split(":")[1]; if (toPushCount !== undefined) bound = +toPushCount - 1; - if (TestWords.words.length - TestInput.input.getHistory().length > bound) { + if (TestWords.words.length - TestState.activeWordIndex > bound) { console.debug("Not adding word, enough words already"); return; } @@ -772,410 +759,7 @@ export async function retrySavingResult(): Promise { await saveResult(completedEvent, true); } -function buildCompletedEvent( - stats: TestStats.Stats, - rawPerSecond: number[], -): Omit { - //build completed event object - let stfk = Numbers.roundTo2( - TestInput.keypressTimings.spacing.first - TestStats.start, - ); - if (stfk < 0 || Config.mode === "zen") { - stfk = 0; - } - - let lkte = Numbers.roundTo2( - TestStats.end - TestInput.keypressTimings.spacing.last, - ); - if (lkte < 0 || Config.mode === "zen") { - lkte = 0; - } - - //consistency - const stddev = Numbers.stdDev(rawPerSecond); - const avg = Numbers.mean(rawPerSecond); - let consistency = Numbers.roundTo2(Numbers.kogasa(stddev / avg)); - let keyConsistencyArray = TestInput.keypressTimings.spacing.array.slice(); - if (keyConsistencyArray.length > 0) { - keyConsistencyArray = keyConsistencyArray.slice( - 0, - keyConsistencyArray.length - 1, - ); - } - let keyConsistency = Numbers.roundTo2( - Numbers.kogasa( - Numbers.stdDev(keyConsistencyArray) / Numbers.mean(keyConsistencyArray), - ), - ); - if (!consistency || isNaN(consistency)) { - consistency = 0; - } - if (!keyConsistency || isNaN(keyConsistency)) { - keyConsistency = 0; - } - - const chartErr = []; - for (const error of TestInput.errorHistory) { - chartErr.push(error.count ?? 0); - } - - const chartData = { - wpm: TestInput.wpmHistory, - burst: rawPerSecond, - err: chartErr, - }; - - //wpm consistency - const stddev3 = Numbers.stdDev(chartData.wpm ?? []); - const avg3 = Numbers.mean(chartData.wpm ?? []); - const wpmCons = Numbers.roundTo2(Numbers.kogasa(stddev3 / avg3)); - const wpmConsistency = isNaN(wpmCons) ? 0 : wpmCons; - - 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, - }; - } - - //tags - const activeTagsIds: string[] = __nonReactive - .getActiveTags() - .map((tag) => tag._id); - - const duration = parseFloat(stats.time.toString()); - const afkDuration = TestStats.calculateAfkSeconds(duration); - let language = Config.language; - if (Config.mode === "quote") { - language = Strings.removeLanguageSize(Config.language); - } - - const quoteLength = TestWords.currentQuote?.group ?? -1; - - const completedEvent: Omit = { - wpm: stats.wpm, - rawWpm: stats.wpmRaw, - charStats: [ - stats.correctChars + stats.correctSpaces, - stats.incorrectChars, - stats.extraChars, - stats.missedChars, - ], - charTotal: stats.allChars, - acc: stats.acc, - mode: Config.mode, - mode2: Misc.getMode2(Config, TestWords.currentQuote), - quoteLength: quoteLength, - punctuation: Config.punctuation, - numbers: Config.numbers, - lazyMode: Config.lazyMode, - timestamp: Date.now(), - language: language, - restartCount: getRestartCount(), - incompleteTests: getIncompleteTests(), - incompleteTestSeconds: - getIncompleteSeconds() < 0 ? 0 : Numbers.roundTo2(getIncompleteSeconds()), - difficulty: Config.difficulty, - blindMode: Config.blindMode, - tags: activeTagsIds, - keySpacing: TestInput.keypressTimings.spacing.array, - keyDuration: TestInput.keypressTimings.duration.array, - keyOverlap: Numbers.roundTo2(TestInput.keyOverlap.total), - lastKeyToEnd: lkte, - startToFirstKey: stfk, - consistency: consistency, - wpmConsistency: wpmConsistency, - keyConsistency: keyConsistency, - funbox: Config.funbox, - bailedOut: TestState.bailedOut, - chartData: chartData, - customText: customText, - testDuration: duration, - afkDuration: afkDuration, - stopOnLetter: Config.stopOnError === "letter", - }; - - if (completedEvent.mode !== "custom") delete completedEvent.customText; - if (completedEvent.mode !== "quote") delete completedEvent.quoteLength; - - return completedEvent; -} - -const ALWAYSREPORT = false; - -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 mismatchedKeys: 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)`); - mismatchedKeys.push(key); - 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(", ")})`); - mismatchedKeys.push("charStats"); - 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)"); - mismatchedKeys.push("chartData"); - 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)`); - mismatchedKeys.push(`chartData.${field}`); - console.error( - `Completed event mismatch on key chartData.${field}:`, - 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)); - const dir = a > b ? "ce1 larger" : "ce2 larger"; - notMatching.push(`${key} (off by ${diff}, ${dir})`); - mismatchedKeys.push(key); - 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)); - const dir = a > b ? "ce1 larger" : "ce2 larger"; - notMatching.push(`${key} (off by ${diff}, ${dir})`); - mismatchedKeys.push(key); - 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)`); - mismatchedKeys.push(key); - console.error(`Completed event mismatch on key ${key}:`, val1, val2); - } else { - console.debug(`Completed event match on key ${key}:`, val1); - } - } - - { - 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)`); - mismatchedKeys.push("keypressCountHistory"); - console.error( - `Completed event mismatch on key keypressCountHistory:`, - a, - b, - ); - } - } - - { - const a = TestInput.keypressCountHistory.reduce((acc, val) => { - if (val === undefined) return acc; - return acc + val; - }, 0); - const b = getKeypressesPerSecond().reduce((acc, val) => { - if (val === undefined) return acc; - return acc + val; - }, 0); - if (a === b) { - console.debug(`Completed event match on totalKeypressCountHistory:`, a); - } else { - notMatching.push(`totalKeypressCountHistory (${a} vs ${b})`); - mismatchedKeys.push("totalKeypressCountHistory"); - console.error( - `Completed event mismatch on totalKeypressCountHistory:`, - a, - b, - ); - } - } - - if (notMatching.length === 0) { - if (ALWAYSREPORT) { - showSuccessNotification("Completed events match", { important: true }); - } - } else { - if (ALWAYSREPORT) { - showErrorNotification( - `Completed event mismatch: ${notMatching.join(", ")}`, - { important: true }, - ); - } - mismatchedKeys.sort(); - const groupKey = mismatchedKeys.join(","); - Ape.results - .reportCompletedEventMismatch({ - body: { - notMatching, - mismatchedKeys, - groupKey, - language: ce.language, - mode: ce.mode, - mode2: ce.mode2, - difficulty: ce.difficulty, - duration: ce.testDuration, - // ce: ce as Record, - // ce2: ce2 as Record, - }, - }) - .catch(() => { - // - }); - } - - console.debug("Completed event object2", ce2); -} - -function buildCompletedEvent2(): Omit { +function buildCompletedEvent(): Omit { const chars = getChars(); //tags @@ -1291,7 +875,6 @@ export async function finish(difficultyFailed = false): Promise { TestState.setResultCalculating(true); const now = performance.now(); TestTimer.clear(true, now); - TestStats.setEnd(now); // fade out the test and show loading // because the css animation has a delay, @@ -1310,96 +893,19 @@ export async function finish(difficultyFailed = false): Promise { TestState.setRepeated(false); } - // in case the tests ends with a keypress (not a word submission) - // we need to push the current input to history - if (TestInput.input.current.length !== 0) { - TestInput.input.pushHistory(); - TestInput.corrected.pushHistory(); - Replay.replayGetWordsList(TestInput.input.getHistory()); - } - - // in zen mode, ensure the replay words list reflects the typed input history - // even if the current input was empty at finish (e.g., after submitting a word). - if (Config.mode === "zen") { - Replay.replayGetWordsList(TestInput.input.getHistory()); - } - - 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) { - TestStats.setEnd(TestInput.keypressTimings.spacing.last); - } - setResultVisible(true); TestState.setResultVisible(true); TestState.setActive(false); - Replay.stopReplayRecording(); cleanupData(); - // logEventsDataToTheConsoleTable(); + logEventsDataToTheConsoleTable(); + console.log(getInputHistory()); - //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); - TestInput.pushBurstToHistory(burst); - } - - //remove afk from zen - if (Config.mode === "zen" || TestState.bailedOut) { - TestStats.removeAfkData(); - } - - // stats - const stats = TestStats.calculateFinalStats(); - if ( - stats.time % 1 !== 0 && - !( - Config.mode === "time" || - (Config.mode === "custom" && CustomText.getLimitMode() === "time") - ) - ) { - TestStats.setLastSecondNotRound(); - } - - PaceCaret.setLastTestWpm(stats.wpm); - - // if the last second was not rounded, add another data point to the history - if ( - TestStats.lastSecondNotRound && - !difficultyFailed && - Math.round(stats.time % 1) >= 0.5 - ) { - const wpmAndRaw = TestStats.calculateWpmAndRaw(); - TestInput.pushToWpmHistory(wpmAndRaw.wpm); - TestInput.pushToRawHistory(wpmAndRaw.raw); - TestInput.pushKeypressesToHistory(); - TestInput.pushErrorToHistory(); - TestInput.pushAfkToHistory(); - } - - const rawPerSecond = TestInput.keypressCountHistory.map((count) => - Math.round((count / 5) * 60), - ); - - //adjust last second if last second is not round - // if (TestStats.lastSecondNotRound && stats.time % 1 >= 0.1) { - if ( - Config.mode !== "time" && - TestStats.lastSecondNotRound && - stats.time % 1 >= 0.5 - ) { - const timescale = 1 / (stats.time % 1); - - //multiply last element of rawBefore by scale, and round it - rawPerSecond[rawPerSecond.length - 1] = Math.round( - (rawPerSecond[rawPerSecond.length - 1] as number) * timescale, - ); - } - - const ce = buildCompletedEvent(stats, rawPerSecond); + const ce = buildCompletedEvent(); + PaceCaret.setLastTestWpm(ce.wpm); console.debug("Completed event object", ce); @@ -1435,16 +941,15 @@ export async function finish(difficultyFailed = false): Promise { ///////// completed event ready //afk check - const kps = TestInput.afkHistory.slice(-5); - let afkDetected = kps.length > 0 && kps.every((afk) => afk); + let afkDetected = false; if (TestState.bailedOut) afkDetected = false; const mode2Number = parseInt(completedEvent.mode2); let tooShort = false; //fail checks - const dateDur = (TestStats.end3 - TestStats.start3) / 1000; + const dateDur = getDateBasedTestDurationMs() / 1000; if ( Config.mode === "time" && !TestState.bailedOut && @@ -1528,19 +1033,7 @@ export async function finish(difficultyFailed = false): Promise { // test is valid - if (ALWAYSREPORT) { - logEventsDataToTheConsoleTable(); - } - - if ( - (getAuthenticatedUser() !== null && - !dontSave && - !difficultyFailed && - Config.resultSaving) || - ALWAYSREPORT - ) { - compareCompletedEvents(ce); - } + logEventsDataToTheConsoleTable(); if (TestState.isRepeated || difficultyFailed) { if (Config.resultSaving) { @@ -1559,11 +1052,11 @@ export async function finish(difficultyFailed = false): Promise { // Let's update the custom text progress if ( TestState.bailedOut || - TestInput.input.getHistory().length < TestWords.words.length + getInputHistory().length < TestWords.words.length ) { // They bailed out - const history = TestInput.input.getHistory(); + const history = getInputHistory(); let historyLength = history?.length; const wordIndex = historyLength - 1; @@ -1792,11 +1285,6 @@ async function saveResult( export function fail(reason: string): void { failReason = reason; - // input.pushHistory(); - // corrected.pushHistory(); - TestInput.pushKeypressesToHistory(); - TestInput.pushErrorToHistory(); - TestInput.pushAfkToHistory(); void finish(true); } diff --git a/frontend/src/ts/test/test-screenshot.ts b/frontend/src/ts/test/test-screenshot.ts index acdf7fdf4f0c..f375d9f439eb 100644 --- a/frontend/src/ts/test/test-screenshot.ts +++ b/frontend/src/ts/test/test-screenshot.ts @@ -1,5 +1,5 @@ import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; -import * as Replay from "./replay"; +import * as Replay from "./events/replay-ui"; import { getActivePage, isAuthenticated, diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index 82c27c083657..831c101d08ec 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -13,6 +13,11 @@ export let isDirectionReversed = false; export let testRestarting = false; export let resultVisible = false; export let resultCalculating = false; +export let koreanStatus = false; + +export function setKoreanStatus(val: boolean): void { + koreanStatus = val; +} export function setRepeated(tf: boolean): void { isRepeated = tf; diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts deleted file mode 100644 index 9d7701fc45f7..000000000000 --- a/frontend/src/ts/test/test-stats.ts +++ /dev/null @@ -1,403 +0,0 @@ -import Hangul from "hangul-js"; -import { Config } from "../config/store"; -import * as Strings from "../utils/strings"; -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 { isFunboxActiveWithProperty } from "./funbox/list"; -import * as CustomText from "./custom-text"; -import { getLastResult } from "../states/test"; - -type CharCount = { - spaces: number; - correctWordChars: number; - allCorrectChars: number; - incorrectChars: number; - extraChars: number; - missedChars: number; - correctSpaces: number; -}; - -export type Stats = { - wpm: number; - wpmRaw: number; - acc: number; - correctChars: number; - incorrectChars: number; - missedChars: number; - extraChars: number; - allChars: number; - time: number; - spaces: number; - correctSpaces: number; -}; - -export let start: number, end: number; -export let start2: number, end2: number; -export let start3: number, end3: number; -export let lastSecondNotRound = false; - -export function getStats(): unknown { - const ret = { - lastResult: getLastResult(), - start, - end, - start3, - end3, - afkHistory: TestInput.afkHistory, - errorHistory: TestInput.errorHistory, - wpmHistory: TestInput.wpmHistory, - rawHistory: TestInput.rawHistory, - burstHistory: TestInput.burstHistory, - keypressCountHistory: TestInput.keypressCountHistory, - currentBurstStart: TestInput.currentBurstStart, - lastSecondNotRound, - missedWords: TestInput.missedWords, - accuracy: TestInput.accuracy, - keypressTimings: TestInput.keypressTimings, - keyOverlap: TestInput.keyOverlap, - wordsHistory: TestWords.words.list.slice( - 0, - TestInput.input.getHistory().length, - ), - inputHistory: TestInput.input.getHistory(), - }; - - try { - // @ts-expect-error --- - ret.keypressTimings.spacing.average = - TestInput.keypressTimings.spacing.array.reduce( - (previous, current) => (current += previous), - ) / TestInput.keypressTimings.spacing.array.length; - - // @ts-expect-error --- - ret.keypressTimings.spacing.sd = Numbers.stdDev( - TestInput.keypressTimings.spacing.array, - ); - } catch (e) { - // - } - try { - // @ts-expect-error --- - ret.keypressTimings.duration.average = - TestInput.keypressTimings.duration.array.reduce( - (previous, current) => (current += previous), - ) / TestInput.keypressTimings.duration.array.length; - - // @ts-expect-error --- - ret.keypressTimings.duration.sd = Numbers.stdDev( - TestInput.keypressTimings.duration.array, - ); - } catch (e) { - // - } - - return ret; -} - -export function restart(): void { - start = 0; - end = 0; - start2 = 0; - end2 = 0; - start3 = 0; - end3 = 0; - lastSecondNotRound = false; -} - -export function calculateTestSeconds(now?: number): number { - let duration = (end - start) / 1000; - - if (now !== undefined) { - duration = (now - start) / 1000; - } - - return duration; -} - -export function calculateWpmAndRaw( - withDecimalPoints?: true, - final = false, - testSecondsOverride?: number, -): { - wpm: number; - raw: number; -} { - const testSeconds = - testSecondsOverride ?? - calculateTestSeconds(TestState.isActive ? performance.now() : end); - - const chars = countChars(final); - const wpm = Numbers.roundTo2( - ((chars.correctWordChars + chars.correctSpaces) * (60 / testSeconds)) / 5, - ); - const raw = Numbers.roundTo2( - ((chars.allCorrectChars + - chars.spaces + - chars.incorrectChars + - chars.extraChars) * - (60 / testSeconds)) / - 5, - ); - return { - wpm: withDecimalPoints ? wpm : Math.round(wpm), - raw: withDecimalPoints ? raw : Math.round(raw), - }; -} - -export function setEnd(e: number): void { - end = e; - end2 = Date.now(); - end3 = new Date().getTime(); -} - -export function setStart(s: number): void { - start = s; - start2 = Date.now(); - start3 = new Date().getTime(); -} - -export function calculateAfkSeconds(testSeconds: number): number { - let extraAfk = 0; - if (testSeconds !== undefined) { - extraAfk = Math.round(testSeconds) - TestInput.keypressCountHistory.length; - if (extraAfk < 0) extraAfk = 0; - // console.log("-- extra afk debug"); - // console.log("should be " + Math.ceil(testSeconds)); - // console.log(keypressPerSecond.length); - // console.log( - // `gonna add extra ${extraAfk} seconds of afk because of no keypress data` - // ); - } - const ret = TestInput.afkHistory.filter((afk) => afk).length; - return ret + extraAfk; -} - -export function setLastSecondNotRound(): void { - lastSecondNotRound = true; -} - -export function calculateBurst(endTime: number = performance.now()): number { - const containsKorean = TestInput.input.getKoreanStatus(); - const timeToWrite = (endTime - TestInput.currentBurstStart) / 1000; - if (timeToWrite <= 0) return 0; - let wordLength: number; - wordLength = !containsKorean - ? TestInput.input.current.length - : Hangul.disassemble(TestInput.input.current).length; - if (wordLength === 0) { - wordLength = !containsKorean - ? (TestInput.input.getHistoryLast()?.length ?? 0) - : (Hangul.disassemble(TestInput.input.getHistoryLast() as string) - ?.length ?? 0); - } - if (wordLength === 0) return 0; - const speed = Numbers.roundTo2((wordLength * (60 / timeToWrite)) / 5); - return Math.round(speed); -} - -export function calculateAccuracy(): number { - const acc = - (TestInput.accuracy.correct / - (TestInput.accuracy.correct + TestInput.accuracy.incorrect)) * - 100; - return isNaN(acc) ? 100 : acc; -} - -export function removeAfkData(): void { - const testSeconds = calculateTestSeconds(); - TestInput.keypressCountHistory.splice(testSeconds); - TestInput.wpmHistory.splice(testSeconds); - TestInput.rawHistory.splice(testSeconds); -} - -function getInputWords(): string[] { - const containsKorean = TestInput.input.getKoreanStatus(); - - let inputWords = [...TestInput.input.getHistory()]; - - if (TestState.isActive) { - inputWords.push(TestInput.input.current); - } - - if (containsKorean) { - inputWords = inputWords.map((w) => Hangul.disassemble(w).join("")); - } - - return inputWords; -} - -function getTargetWords(): string[] { - const containsKorean = TestInput.input.getKoreanStatus(); - - let targetWords = [ - ...(Config.mode === "zen" - ? TestInput.input.getHistory() - : TestWords.words.list), - ]; - - if (TestState.isActive) { - targetWords.push( - Config.mode === "zen" - ? TestInput.input.current - : TestWords.words.getCurrentText(), - ); - } - - if (containsKorean) { - targetWords = targetWords.map((w) => Hangul.disassemble(w).join("")); - } - - return targetWords; -} - -function countChars(final = false): CharCount { - let correctWordChars = 0; - let correctChars = 0; - let incorrectChars = 0; - let extraChars = 0; - let missedChars = 0; - let spaces = 0; - let correctspaces = 0; - - const inputWords = getInputWords(); - const targetWords = getTargetWords(); - - for (let i = 0; i < inputWords.length; i++) { - const inputWord = inputWords[i] as string; - const targetWord = targetWords[i] as string; - - if (inputWord === targetWord) { - //the word is correct - correctWordChars += targetWord.length; - correctChars += targetWord.length; - if ( - i < inputWords.length - 1 && - Strings.getLastChar(inputWord) !== "\n" - ) { - correctspaces++; - } - } else if (inputWord.length >= targetWord.length) { - //too many chars - for (let c = 0; c < inputWord.length; c++) { - if (c < targetWord.length) { - //on char that still has a word list pair - if (inputWord[c] === targetWord[c]) { - correctChars++; - } else { - incorrectChars++; - } - } else { - //on char that is extra - extraChars++; - } - } - } else { - //not enough chars - const toAdd = { - correct: 0, - incorrect: 0, - missed: 0, - }; - for (let c = 0; c < targetWord.length; c++) { - if (c < inputWord.length) { - //on char that still has a word list pair - if (inputWord[c] === targetWord[c]) { - toAdd.correct++; - } else { - toAdd.incorrect++; - } - } else { - //on char that is extra - toAdd.missed++; - } - } - correctChars += toAdd.correct; - incorrectChars += toAdd.incorrect; - - const isTimedTest = - Config.mode === "time" || - (Config.mode === "custom" && CustomText.getLimit().mode === "time"); - const shouldCountPartialLastWord = !final || (final && isTimedTest); - - if (i === inputWords.length - 1 && shouldCountPartialLastWord) { - //last word - check if it was all correct - add to correct word chars - if (toAdd.incorrect === 0) correctWordChars += toAdd.correct; - } else { - missedChars += toAdd.missed; - } - } - if (i < inputWords.length - 1) { - spaces++; - } - } - if (isFunboxActiveWithProperty("nospace")) { - spaces = 0; - correctspaces = 0; - } - return { - spaces: spaces, - correctWordChars: correctWordChars, - allCorrectChars: correctChars, - incorrectChars: - Config.mode === "zen" ? TestInput.accuracy.incorrect : incorrectChars, - extraChars: extraChars, - missedChars: missedChars, - correctSpaces: correctspaces, - }; -} - -export function calculateFinalStats(): Stats { - console.debug("Calculating result stats"); - let testSeconds = calculateTestSeconds(); - console.debug( - "Test seconds", - testSeconds, - " (date based) ", - (end2 - start2) / 1000, - " (performance.now based)", - (end3 - start3) / 1000, - " (new Date based)", - ); - console.debug( - "Test seconds", - Numbers.roundTo1(testSeconds), - " (date based) ", - Numbers.roundTo1((end2 - start2) / 1000), - " (performance.now based)", - Numbers.roundTo1((end3 - start3) / 1000), - " (new Date based)", - ); - if (Config.mode !== "custom") { - testSeconds = Numbers.roundTo2(testSeconds); - console.debug( - "Mode is not custom - rounding to 2. New time: ", - testSeconds, - ); - } - - //todo: this counts chars twice - once here and once in calculateWpmAndRaw - const chars = countChars(true); - const { wpm, raw } = calculateWpmAndRaw(true, true, testSeconds); - const acc = Numbers.roundTo2(calculateAccuracy()); - const ret = { - wpm: isNaN(wpm) ? 0 : wpm, - wpmRaw: isNaN(raw) ? 0 : raw, - acc: acc, - correctChars: chars.correctWordChars, - incorrectChars: chars.incorrectChars + chars.spaces - chars.correctSpaces, - missedChars: chars.missedChars, - extraChars: chars.extraChars, - allChars: - chars.allCorrectChars + - chars.spaces + - chars.incorrectChars + - chars.extraChars, - time: Numbers.roundTo2(testSeconds), - spaces: chars.spaces, - correctSpaces: chars.correctSpaces, - }; - console.debug("Result stats", ret); - return ret; -} diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index b7467e0dab62..118ad2e6e554 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -6,11 +6,8 @@ import { setConfig } from "../config/setters"; import * as CustomText from "./custom-text"; import * as TimerProgress from "./timer-progress"; import * as LiveSpeed from "./live-speed"; -import * as TestStats from "./test-stats"; -import * as TestInput from "./test-input"; import * as TestWords from "./test-words"; import * as Monkey from "./monkey"; -import * as Numbers from "@monkeytype/util/numbers"; import { showNoticeNotification, showErrorNotification, @@ -28,7 +25,8 @@ 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"; +import { getCurrentInput, logTestEvent } from "./events/data"; +import { getCurrentAccuracy, getCurrentWpmAndRaw } from "./events/stats"; let lastLoop = 0; const newTimer = createTimer({ @@ -86,6 +84,7 @@ export function clear(logEnd = false, now = performance.now()): void { logTestEvent("timer", now, { event: "end", timer: Time.get(), + date: new Date().getTime(), }); } } @@ -100,17 +99,6 @@ function premid(): void { if (timerDebug) console.timeEnd("premid"); } -function calculateWpmRaw(): { wpm: number; raw: number } { - if (timerDebug) console.time("calculate wpm and raw"); - const wpmAndRaw = TestStats.calculateWpmAndRaw(); - if (timerDebug) console.timeEnd("calculate wpm and raw"); - if (timerDebug) console.time("push to history"); - TestInput.pushToWpmHistory(wpmAndRaw.wpm); - TestInput.pushToRawHistory(wpmAndRaw.raw); - if (timerDebug) console.timeEnd("push to history"); - return wpmAndRaw; -} - function monkey(wpmAndRaw: { wpm: number; raw: number }): void { if (timerDebug) console.time("update monkey"); const num = Config.blindMode ? wpmAndRaw.raw : wpmAndRaw.wpm; @@ -118,13 +106,6 @@ function monkey(wpmAndRaw: { wpm: number; raw: number }): void { if (timerDebug) console.timeEnd("update monkey"); } -function calculateAcc(): number { - if (timerDebug) console.time("calculate acc"); - const acc = Numbers.roundTo2(TestStats.calculateAccuracy()); - if (timerDebug) console.timeEnd("calculate acc"); - return acc; -} - function layoutfluid(): void { if (timerDebug) console.time("layoutfluid"); if (Config.funbox.includes("layoutfluid") && Config.mode === "time") { @@ -160,9 +141,7 @@ function layoutfluid(): void { if (Config.keymapMode === "next") { setTimeout(() => { highlight( - TestWords.words - .getCurrentText() - .charAt(TestInput.input.current.length), + TestWords.words.getCurrentText().charAt(getCurrentInput().length), ); }, 1); } @@ -176,9 +155,6 @@ function checkIfFailed( acc: number, ): boolean { if (timerDebug) console.time("fail conditions"); - TestInput.pushKeypressesToHistory(); - TestInput.pushErrorToHistory(); - TestInput.pushAfkToHistory(); if ( Config.minWpm === "custom" && wpmAndRaw.wpm < Config.minWpmCustomSpeed && @@ -214,8 +190,6 @@ function checkIfTimeIsUp(): void { //times up if (timer !== null) clearTimeout(timer); Caret.hide(); - TestInput.input.pushHistory(); - TestInput.corrected.pushHistory(); SlowTimer.clear(); slowTimerCount = 0; timerEvent.dispatch({ key: "finish" }); @@ -258,8 +232,8 @@ function timerStep(): void { //calc Time.increment(); - const wpmAndRaw = calculateWpmRaw(); - const acc = calculateAcc(); + const wpmAndRaw = getCurrentWpmAndRaw(); + const acc = getCurrentAccuracy(); //ui updates requestDebouncedAnimationFrame("test-timer.timerStep", () => { @@ -313,28 +287,29 @@ function checkIfTimerIsSlow(drift: number): void { } } -export async function start(): Promise { +export async function start(now: number): Promise { SlowTimer.clear(); slowTimerCount = 0; for (const id of slowTimerNotifIds) { removeNotification(id, "clear"); } slowTimerNotifIds = []; - void _startNew(); + void _startNew(now); // void _startOld(); } -async function _startNew(): Promise { +async function _startNew(now: number): Promise { newTimer.play(); - logTestEvent("timer", performance.now(), { + logTestEvent("timer", now, { event: "start", timer: Time.get(), + date: new Date().getTime(), }); } async function _startOld(): Promise { timerStats = []; - expected = TestStats.start + interval; + // expected = TestStats.start + interval; logTestEvent("timer", performance.now(), { event: "start", timer: Time.get(), diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index ffc105d1dfda..3c01671eee76 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -6,7 +6,7 @@ import { import { Config } from "../config/store"; import { setConfig } from "../config/setters"; import * as TestWords from "./test-words"; -import * as TestInput from "./test-input"; +import { getCurrentInput } from "./events/data"; import * as CustomText from "./custom-text"; import * as Caret from "./caret"; import * as OutOfFocus from "./out-of-focus"; @@ -34,7 +34,6 @@ import { } from "../utils/debounced-animation-frame"; import * as SoundController from "../controllers/sound-controller"; import * as Numbers from "@monkeytype/util/numbers"; -import * as TestStats from "./test-stats"; import { highlight } from "../events/keymap"; import * as LiveAcc from "./live-acc"; import * as Focus from "../test/focus"; @@ -69,6 +68,13 @@ import { import { getTheme } from "../states/theme"; import { skipBreakdownEvent } from "../states/header"; import { wordsHaveNewline } from "../states/test"; +import { + getBurstHistory, + getCorrectedWords, + getCurrentAccuracy, + getInputHistory, + getMissedWords, +} from "./events/stats"; export const updateHintsPositionDebounced = Misc.debounceUntilResolved( updateHintsPosition, @@ -493,7 +499,7 @@ function showWords(): void { wordsEl.setHtml(""); if (Config.mode === "zen") { - appendEmptyWordElement(); + appendEmptyWordElement(0); } else { let wordsHTML = ""; for (let i = 0; i < TestWords.words.length; i++) { @@ -509,9 +515,7 @@ function showWords(): void { PaceCaret.resetCaretPosition(); } -export function appendEmptyWordElement( - index = TestInput.input.getHistory().length, -): void { +export function appendEmptyWordElement(index: number): void { wordsEl.appendHtml( `
`, ); @@ -1068,7 +1072,7 @@ export async function scrollTape(noAnimation = false): Promise { /* calculate current word width to add to #words margin */ let currentWordWidth = 0; - const inputLength = TestInput.input.current.length; + const inputLength = getCurrentInput().length; if (Config.tapeMode === "letter" && inputLength > 0) { const letters = activeWordEl.qsa("letter"); let lastPositiveLetterWidth = 0; @@ -1289,7 +1293,7 @@ function buildWordLettersHTML( }`; } } else { - if (inputCharacters[c] === TestInput.input.current) { + if (inputCharacters[c] === getCurrentInput()) { out += `${ wordCharacters[c] }`; @@ -1312,10 +1316,13 @@ async function loadWordsHistory(): Promise { const wordsContainer = qs("#resultWordsHistory .words"); wordsContainer?.empty(); - const inputHistoryLength = TestInput.input.getHistory().length; + const inputHistory = getInputHistory(); + const burstHistory = getBurstHistory(); + const correctedHistory = getCorrectedWords(); + const inputHistoryLength = inputHistory.length; for (let i = 0; i < inputHistoryLength + 2; i++) { - const input = TestInput.input.getHistory(i); - const corrected = TestInput.corrected.getHistory(i); + const input = inputHistory[i]; + const corrected = correctedHistory[i]; const word = TestWords.words.getText(i) ?? ""; const koreanRegex = /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/; @@ -1350,7 +1357,7 @@ async function loadWordsHistory(): Promise { wordEl.classList.add("error"); } - const burstValue = TestInput.burstHistory[i]; + const burstValue = burstHistory[i]; if (burstValue !== undefined) { wordEl.setAttribute("burst", String(burstValue)); } @@ -1459,7 +1466,8 @@ export async function applyBurstHeatmap(): Promise { if (Config.burstHeatmap) { qsa("#resultWordsHistory .heatmapLegend")?.show(); - let burstlist = [...TestInput.burstHistory]; + const burstHistory = getBurstHistory(); + let burstlist = [...burstHistory]; burstlist = burstlist.map((x) => (x >= 1000 ? Infinity : x)); @@ -1734,7 +1742,7 @@ function afterAnyTestInput( void SoundController.playClick(); } - const acc: number = Numbers.roundTo2(TestStats.calculateAccuracy()); + const acc: number = Numbers.roundTo2(getCurrentAccuracy()); if (!isNaN(acc)) LiveAcc.update(acc); if (Config.mode !== "time") { @@ -1743,7 +1751,7 @@ function afterAnyTestInput( if (Config.keymapMode === "next") { highlight( - TestWords.words.getCurrentText().charAt(TestInput.input.current.length), + TestWords.words.getCurrentText().charAt(getCurrentInput().length), ); } @@ -1764,7 +1772,7 @@ export function afterTestTextInput( if (!increasedWordIndex) { void updateWordLetters({ - input: inputOverride ?? TestInput.input.current, + input: inputOverride ?? getCurrentInput(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -1775,7 +1783,7 @@ export function afterTestTextInput( export function afterTestCompositionUpdate(): void { void updateWordLetters({ - input: TestInput.input.current, + input: getCurrentInput(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -1785,7 +1793,7 @@ export function afterTestCompositionUpdate(): void { export function afterTestDelete(): void { void updateWordLetters({ - input: TestInput.input.current, + input: getCurrentInput(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -1815,7 +1823,7 @@ export function beforeTestWordChange( Config.strictSpace ) { void updateWordLetters({ - input: TestInput.input.current, + input: getCurrentInput(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -1832,13 +1840,13 @@ export function beforeTestWordChange( export async function afterTestWordChange( direction: "forward" | "back", + lastBurst?: number, ): Promise { updateActiveElement({ direction, }); Caret.updatePosition(); - const lastBurst = TestInput.burstHistory[TestInput.burstHistory.length - 1]; if (Numbers.isSafeNumber(lastBurst)) { void LiveBurst.update(Math.round(lastBurst)); } @@ -1944,11 +1952,11 @@ export function onTestFinish(): void { qs(".pageTest #copyWordsListButton")?.on("click", async () => { let words; if (Config.mode === "zen") { - words = TestInput.input.getHistory().join(" "); + words = getInputHistory().join(" "); } else { words = TestWords.words .getText() - .slice(0, TestInput.input.getHistory().length) + .slice(0, getInputHistory().length) .join(" "); } await copyToClipboard(words); @@ -1957,9 +1965,9 @@ qs(".pageTest #copyWordsListButton")?.on("click", async () => { qs(".pageTest #copyMissedWordsListButton")?.on("click", async () => { let words; if (Config.mode === "zen") { - words = TestInput.input.getHistory().join(" "); + words = getInputHistory().join(" "); } else { - words = Object.keys(TestInput.missedWords ?? {}).join(" "); + words = (Object.keys(getMissedWords()) ?? {}).join(" "); } await copyToClipboard(words); }); @@ -2050,7 +2058,7 @@ configEvent.subscribe(({ key, newValue }) => { if (key === "highlightMode") { if (getActivePage() === "test") { void updateWordLetters({ - input: TestInput.input.current, + input: getCurrentInput(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index 89a41bc521e9..717748e9bbbc 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -2,7 +2,6 @@ import { Config } from "../config/store"; import * as CustomText from "./custom-text"; import * as DateTime from "../utils/date-and-time"; import * as TestWords from "./test-words"; -import * as TestInput from "./test-input"; import * as Time from "../legacy-states/time"; import * as TestState from "./test-state"; import { configEvent } from "../events/config"; @@ -111,12 +110,12 @@ function getCurrentCount(): number { 1 ); } else { - return TestInput.input.getHistory().length; + return TestState.activeWordIndex; } } function setTimerHtmlToInputLength(el: HTMLElement, wrapInDiv: boolean): void { - let historyLength = `${TestInput.input.getHistory().length}`; + let historyLength = `${TestState.activeWordIndex}`; if (wrapInDiv) { historyLength = `
${historyLength}
`; diff --git a/frontend/src/ts/test/weak-spot.ts b/frontend/src/ts/test/weak-spot.ts index b248ddb6f46c..9bfce4c53a0a 100644 --- a/frontend/src/ts/test/weak-spot.ts +++ b/frontend/src/ts/test/weak-spot.ts @@ -1,4 +1,4 @@ -import * as TestInput from "./test-input"; +import { getKeypressSpacing } from "./events/stats"; import { Wordset } from "./wordset"; // Changes how quickly it 'learns' scores - very roughly the score for a char @@ -33,7 +33,7 @@ class Score { } export function updateScore(char: string, isCorrect: boolean): void { - const timings = TestInput.keypressTimings.spacing.array; + const timings = getKeypressSpacing(); if (timings.length === 0 || typeof timings === "string") { return; }