diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index c119b5e5a969..02b332b05662 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -200,6 +200,7 @@ export async function reportCompletedEventMismatch( duration, funboxes, version, + data, } = req.body; // Logger.warning( // `Completed event mismatch for uid ${uid}: ${notMatching.join(", ")}`, @@ -219,6 +220,7 @@ export async function reportCompletedEventMismatch( duration, funboxes, version, + data, }, uid, ); diff --git a/frontend/__tests__/test/events/data.spec.ts b/frontend/__tests__/test/events/data.spec.ts index e9b9e075103f..99c6f336d7b6 100644 --- a/frontend/__tests__/test/events/data.spec.ts +++ b/frontend/__tests__/test/events/data.spec.ts @@ -24,17 +24,11 @@ import type { import { Keycode } from "../../../src/ts/constants/keys"; function keyDown(code: Keycode | "NoCode" = "KeyA"): KeydownEventData { - return { code, ctrl: false, shift: false, alt: false, meta: false }; + return { code }; } function keyUp(code: Keycode | "NoCode" = "KeyA"): KeyupEventData { - return { - code, - ctrl: false, - shift: false, - alt: false, - meta: false, - }; + return { code }; } function inputData( @@ -118,10 +112,14 @@ describe("data.ts", () => { expect(getAllTestEvents()).toHaveLength(0); }); - it("ignores duplicate keydown without keyup", () => { + it("synthesizes missing keyup on duplicate keydown", () => { logTestEvent("keydown", 1010, keyDown()); logTestEvent("keydown", 1020, keyDown()); - expect(getAllTestEvents()).toHaveLength(1); + const events = getAllTestEvents(); + expect(events).toHaveLength(3); + expect(events[0]!.type).toBe("keydown"); + expect(events[1]!.type).toBe("keyup"); + expect(events[2]!.type).toBe("keydown"); }); it("allows keydown after keyup", () => { @@ -211,17 +209,9 @@ describe("data.ts", () => { // simulate forceReleaseAllKeys passing indexed codes directly logTestEvent("keyup", 1030, { code: "NoCode0", - ctrl: false, - shift: false, - alt: false, - meta: false, } as KeyupEventData); logTestEvent("keyup", 1040, { code: "NoCode1", - ctrl: false, - shift: false, - alt: false, - meta: false, } as KeyupEventData); const events = getAllTestEvents(); @@ -235,10 +225,6 @@ describe("data.ts", () => { it("rejects indexed NoCode keyup with no matching keydown", () => { logTestEvent("keyup", 1010, { code: "NoCode0", - ctrl: false, - shift: false, - alt: false, - meta: false, } as KeyupEventData); expect(getAllTestEvents()).toHaveLength(0); diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index d7eea1a519f6..b8b5273b2fe4 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -72,17 +72,11 @@ import * as TestState from "../../../src/ts/test/test-state"; import { words as TestWords } from "../../../src/ts/test/test-words"; function keyDown(code: Keycode = "KeyA"): KeydownEventData { - return { code, ctrl: false, shift: false, alt: false, meta: false }; + return { code }; } function keyUp(code: Keycode = "KeyA"): KeyupEventData { - return { - code, - ctrl: false, - shift: false, - alt: false, - meta: false, - }; + return { code }; } function input( @@ -561,6 +555,30 @@ describe("stats.ts", () => { expect(getKeypressSpacing()).toEqual([]); }); + it("clamps a pre-start first keydown so the timing invariant holds", () => { + // A keydown can be recorded before timer:start (e.g. a stray Ctrl+H + // pressed seconds before the user starts typing). cleanupData keeps the + // last pre-start keydown by design, and getStartToFirstKeypressMs clamps + // its negative offset to 0 — so the first spacing must clamp the same + // way, else sum(keySpacing) inflates by |firstKeydown| and breaks + // the testDuration vs key timings check. + (Config as { mode: string }).mode = "time"; + logTestEvent("keydown", -16240, keyDown()); + logTestEvent("timer", 0, timer("start", 0)); + logTestEvent("input", 0, input()); + logTestEvent("keyup", 100, keyUp()); + logTestEvent("keydown", 500, keyDown("KeyS")); + logTestEvent("keyup", 580, keyUp("KeyS")); + logTestEvent("timer", 1000, timer("step", 1)); + logTestEvent("timer", 1000, timer("end", 1)); + + const sumSpacing = getKeypressSpacing().reduce((a, b) => a + b, 0); + const total = + getStartToFirstKeypressMs() + sumSpacing + getLastKeypressToEndMs(); + + expect(Math.abs(getTestDurationMs() - total)).toBeLessThan(100); + }); + it("cleanupData drops post-end keydowns so the timing invariant holds", () => { // The compareCompletedEvents check in test-logic.ts relies on: // startToFirstKey + sum(keySpacing) + lastKeyToEnd ≈ testDuration @@ -987,9 +1005,9 @@ describe("stats.ts", () => { const keyup = events.find( (e) => e.type === "keyup" && e.data.code === "KeyD", ); - // avg duration = (80+120)/2 = 100, so keyup at 1400+100 = 1500 + // avg duration = (80+120)/2 = 100, so keyup at 1400+100 = 1500, testMs = 1500 - 1000 = 500 expect(keyup).toBeDefined(); - expect(keyup!.ms).toBe(1500); + expect(keyup!.testMs).toBe(500); }); it("uses default 80ms when no completed key durations exist", () => { @@ -1003,7 +1021,7 @@ describe("stats.ts", () => { (e) => e.type === "keyup" && e.data.code === "KeyA", ); expect(keyup).toBeDefined(); - expect(keyup!.ms).toBe(1280); + expect(keyup!.testMs).toBe(280); }); it("does nothing when no keys are pressed", () => { diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index fb9f453a7ea9..e4f87c7e4f27 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -229,7 +229,11 @@ export async function onInsertText(options: OnInsertTextParams): Promise { charIndex: testInput.length, isCompositionEnding: isCompositionEnding === true, inputStopped: removeLastChar, - inputValue: inputValueAfterEvent + (charIsSpace ? " " : ""), + // when shouldInsertSpace is true, the space char was already inserted via + // syncWithInputElement above — only append " " for the advance-space case, + // else recorded inputValue ends up with a doubled trailing space. + inputValue: + inputValueAfterEvent + (charIsSpace && !shouldInsertSpace ? " " : ""), }); // going to next word diff --git a/frontend/src/ts/input/handlers/keydown.ts b/frontend/src/ts/input/handlers/keydown.ts index 9e4a319b5a26..780e8dd8f997 100644 --- a/frontend/src/ts/input/handlers/keydown.ts +++ b/frontend/src/ts/input/handlers/keydown.ts @@ -140,10 +140,10 @@ export async function onKeydown(event: KeyboardEvent): Promise { logTestEvent("keydown", now, { code: getTestEventCode(event), - ctrl: event.ctrlKey, - shift: event.shiftKey, - alt: event.altKey, - meta: event.metaKey, + ctrl: event.ctrlKey ? true : undefined, + shift: event.shiftKey ? true : undefined, + alt: event.altKey ? true : undefined, + meta: event.metaKey ? true : undefined, }); // allow arrows in arrows funbox diff --git a/frontend/src/ts/input/handlers/keyup.ts b/frontend/src/ts/input/handlers/keyup.ts index 2e04d12a7aba..6bf90eda40c8 100644 --- a/frontend/src/ts/input/handlers/keyup.ts +++ b/frontend/src/ts/input/handlers/keyup.ts @@ -9,10 +9,10 @@ export async function onKeyup(event: KeyboardEvent): Promise { TestInput.recordKeyupTime(now, event); logTestEvent("keyup", now, { code: getTestEventCode(event), - ctrl: event.ctrlKey, - shift: event.shiftKey, - alt: event.altKey, - meta: event.metaKey, + ctrl: event.ctrlKey ? true : undefined, + shift: event.shiftKey ? true : undefined, + alt: event.altKey ? true : undefined, + meta: event.metaKey ? true : undefined, }); // allow arrows in arrows funbox diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index 7f697c2697d8..9cc59ce380a3 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -12,6 +12,7 @@ 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 * as TestState from "../../test/test-state"; import { activeWordIndex } from "../../test/test-state"; import { areAllTestWordsGenerated } from "../../test/test-logic"; @@ -94,6 +95,9 @@ inputEl.addEventListener("input", async (event) => { return; } + // just in case before input doesn't catch this + if (TestState.resultCalculating || TestState.testRestarting) return; + const now = performance.now(); const inputType = event.inputType; diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index 67a62c88e6e9..6b392b87a55f 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -7,8 +7,9 @@ import { KeydownEventData, KeyupEvent, KeyupEventData, - TestEvent, + InputEventNoMs, TestEventData, + TestEventNoMs, TestEventType, TimerEvent, TimerEventData, @@ -24,7 +25,10 @@ let timerEvents: TimerEvent[] = []; let inputEvents: InputEvent[] = []; let compositionEvents: CompositionTestEvent[] = []; -let cachedAllEvents: TestEvent[] | undefined; +let cachedAllEvents: TestEventNoMs[] | undefined; + +const sortTieRank = (type: TestEventType): number => + type === "keyup" ? 0 : type === "keydown" ? 1 : type === "timer" ? 3 : 2; let noCodeIndex = 0; let pressedKeys: Map< @@ -52,8 +56,13 @@ export function logTestEvent( } if (pressedKeys.has(code)) { - //already pressed - ignore - return; + pressedKeys.delete(code); + keyupEvents.push({ + type: "keyup", + ms: now, + testMs: 0, + data: { ...data, code }, + }); } if (resultCalculating) { @@ -218,7 +227,7 @@ export function cleanupData(): void { ); } -export function getAllTestEvents(): TestEvent[] { +export function getAllTestEvents(): TestEventNoMs[] { if (cachedAllEvents !== undefined) return cachedAllEvents; const firstEventMs = Math.min( @@ -243,15 +252,11 @@ export function getAllTestEvents(): TestEvent[] { ...inputEvents, ...compositionEvents, ] - .sort( - (a, b) => - a.ms - b.ms || - (a.type === "timer" ? 1 : 0) - (b.type === "timer" ? 1 : 0), - ) - .map((event) => { - event.testMs = roundTo2(event.ms - startEventMs); - return event; - }); + .sort((a, b) => a.ms - b.ms || sortTieRank(a.type) - sortTieRank(b.type)) + .map(({ ms, ...rest }) => ({ + ...rest, + testMs: roundTo2(ms - startEventMs), + })); return cachedAllEvents; } @@ -307,9 +312,9 @@ export function resetTestEvents(): void { noCodeIndex = 0; } -export function getInputEvents(): InputEvent[] { +export function getInputEvents(): InputEventNoMs[] { return getAllTestEvents().filter( - (event): event is InputEvent => event.type === "input", + (event): event is InputEventNoMs => event.type === "input", ); } @@ -320,9 +325,9 @@ export function getPressedKeys(): Map< return pressedKeys; } -export function getInputEventsForWord(wordIndex: number): InputEvent[] { +export function getInputEventsForWord(wordIndex: number): InputEventNoMs[] { const events = getAllTestEvents(); - const result: InputEvent[] = []; + const result: InputEventNoMs[] = []; for (const event of events) { if (event.type !== "input") continue; if (event.data.wordIndex === wordIndex) { @@ -335,8 +340,8 @@ export function getInputEventsForWord(wordIndex: number): InputEvent[] { export function getInputEventsPerWord( startMs?: number, testMsLimit?: number, -): Map { - let eventsPerWordIndex: Map = new Map(); +): Map { + let eventsPerWordIndex: Map = new Map(); const events = getAllTestEvents(); for (const event of events) { if (event.type !== "input") { diff --git a/frontend/src/ts/test/events/helpers.ts b/frontend/src/ts/test/events/helpers.ts index b5d7186f05e4..09411f352553 100644 --- a/frontend/src/ts/test/events/helpers.ts +++ b/frontend/src/ts/test/events/helpers.ts @@ -1,6 +1,6 @@ import { Config } from "../../config/store"; import { Keycode } from "../../constants/keys"; -import { InputEvent } from "./types"; +import { InputEventNoMs } from "./types"; export const keysToTrack = new Set([ "NumpadMultiply", @@ -93,7 +93,7 @@ export function getTestEventCode(event: KeyboardEvent): Keycode | "NoCode" { return event.code as Keycode; } -export function applyOp(input: string, event: InputEvent): string { +export function applyOp(input: string, event: InputEventNoMs): string { if (event.data.inputType === "insertText") { if (event.data.inputStopped) return input; return input + event.data.data; @@ -116,7 +116,7 @@ export function applyOp(input: string, event: InputEvent): string { * recorded inputValue field. Use for verification, tests, or fallback — * not as source of truth. */ -export function getInputFromEvents(events: InputEvent[]): string { +export function getInputFromEvents(events: InputEventNoMs[]): string { let input = ""; for (const event of events) { input = applyOp(input, event); @@ -133,13 +133,13 @@ export function getInputFromEvents(events: InputEvent[]): string { * 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 { +export function getInputFromDom(events: InputEventNoMs[]): string { for (let i = events.length - 1; i >= 0; i--) { - const event = events[i] as InputEvent; + const event = events[i] as InputEventNoMs; 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); + input = applyOp(input, events[j] as InputEventNoMs); } return input; } @@ -159,13 +159,13 @@ export type InputValueMismatch = { * DOM captured. Useful for catching op-logic bugs or capture-timing bugs. */ export function findInputValueMismatches( - events: InputEvent[], + events: InputEventNoMs[], ): InputValueMismatch[] { const mismatches: InputValueMismatch[] = []; let derived = ""; for (let i = 0; i < events.length; i++) { - const event = events[i] as InputEvent; + const event = events[i] as InputEventNoMs; derived = applyOp(derived, event); if ( diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index d544c053d21c..e55ebe94bafa 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -13,12 +13,12 @@ import { getInputFromDom } from "./helpers"; import { activeWordIndex, bailedOut, koreanStatus } from "../test-state"; import { calculateWpm } from "../../utils/numbers"; import { mean, roundTo2 } from "@monkeytype/util/numbers"; -import { InputEvent, TestEvent } from "./types"; +import { InputEventNoMs, TestEventNoMs } from "./types"; import { Config } from "../../config/store"; import { isFunboxActiveWithProperty } from "../funbox/list"; import Hangul from "hangul-js"; -function getTimerBoundaries(events: TestEvent[]): number[] { +function getTimerBoundaries(events: TestEventNoMs[]): number[] { const boundaries: number[] = []; let endMs: number | undefined; @@ -146,7 +146,7 @@ export function getLastKeypressToEndMs(): number { return getRawLastKeypressToEndMs(); } -function countPerInterval(predicate: (event: TestEvent) => boolean): { +function countPerInterval(predicate: (event: TestEventNoMs) => boolean): { counts: number[]; boundaries: number[]; } { @@ -267,7 +267,7 @@ function getTargetWord( } function countCharsForWords( - eventsPerWord: Map, + eventsPerWord: Map, lastWordIndex: number, shouldCountPartialLastWord: boolean, ): CharCounts { @@ -286,9 +286,6 @@ function countCharsForWords( if (koreanStatus) { simulatedInput = Hangul.disassemble(simulatedInput).join(""); } - if (lastWord) { - simulatedInput = simulatedInput.trimEnd(); - } let targetWord = getTargetWord(wordIndex, simulatedInput, lastWord); if (koreanStatus) { @@ -314,10 +311,10 @@ function countCharsForWords( } function inferActiveWordIndex( - eventsPerWord: Map, + eventsPerWord: Map, ): number { let maxWordIndex = -1; - let lastWordEvents: InputEvent[] | undefined; + let lastWordEvents: InputEventNoMs[] | undefined; for (const [k, wordEvents] of eventsPerWord) { if (getInputFromDom(wordEvents).length > 0 && k > maxWordIndex) { maxWordIndex = k; @@ -391,10 +388,12 @@ export function getKeypressSpacing(): number[] { for (const event of events) { if (event.type === "keydown") { if (lastKeydownTime !== undefined) { - const spacing = event.ms - lastKeydownTime; + const spacing = event.testMs - lastKeydownTime; spacings.push(spacing); } - lastKeydownTime = event.ms; + // clamp to 0 so a pre-start keydown matches getStartToFirstKeypressMs, + // keeping startToFirstKey + sum(keySpacing) + lastKeyToEnd ≈ testDuration + lastKeydownTime = Math.max(0, event.testMs); } } @@ -416,15 +415,15 @@ export function getKeypressOverlap(): number { for (const event of events) { if (event.type === "keydown") { keydownTimes.set(event.data.code, { - timestamp: event.ms, + timestamp: event.testMs, }); if (lastStartTime === undefined && keydownTimes.size > 1) { - lastStartTime = event.ms; + lastStartTime = event.testMs; } } else if (event.type === "keyup") { keydownTimes.delete(event.data.code); if (lastStartTime !== undefined && keydownTimes.size === 1) { - const endTime = event.ms; + const endTime = event.testMs; overlap += endTime - lastStartTime; lastStartTime = undefined; } @@ -483,14 +482,14 @@ export function getKeypressDurations(): number[] { for (const event of events) { if (event.type === "keydown") { keydownTimes.set(event.data.code, { - timestamp: event.ms, + timestamp: event.testMs, index: durations.length, }); durations.push(0); // placeholder } else if (event.type === "keyup") { const keydownTime = keydownTimes.get(event.data.code); if (keydownTime !== undefined) { - const duration = event.ms - keydownTime.timestamp; + const duration = event.testMs - keydownTime.timestamp; durations[keydownTime.index] = duration; keydownTimes.delete(event.data.code); } @@ -515,10 +514,6 @@ export function forceReleaseAllKeys(): void { for (const [key, { timestamp }] of getPressedKeys().entries()) { logTestEvent("keyup", timestamp + avg, { code: key, //entries is not picking up the type - ctrl: false, - shift: false, - alt: false, - meta: false, estimated: true, }); } diff --git a/frontend/src/ts/test/events/types.ts b/frontend/src/ts/test/events/types.ts index 1d7b64370f94..8f9909549b63 100644 --- a/frontend/src/ts/test/events/types.ts +++ b/frontend/src/ts/test/events/types.ts @@ -25,6 +25,15 @@ export type TestEvent = | InputEvent | CompositionTestEvent; +export type TestEventNoMs = + | Omit + | Omit + | Omit + | InputEventNoMs + | Omit; + +export type InputEventNoMs = Omit; + export type TestEventData = | KeydownEventData | KeyupEventData @@ -36,20 +45,20 @@ export type KeydownEvent = EventProps<"keydown", KeydownEventData>; export type KeydownEventData = { code: Keycode | "NoCode" | `NoCode${number}`; - ctrl: boolean; - shift: boolean; - alt: boolean; - meta: boolean; + ctrl?: true; + shift?: true; + alt?: true; + meta?: true; }; export type KeyupEvent = EventProps<"keyup", KeyupEventData>; export type KeyupEventData = { code: Keycode | "NoCode" | `NoCode${number}`; - ctrl: boolean; - shift: boolean; - alt: boolean; - meta: boolean; + ctrl?: true; + shift?: true; + alt?: true; + meta?: true; estimated?: true; // true if this event never happened, but was estimated (force keyup on test end) }; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 31c0d30c113d..cfca54c26af5 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -97,6 +97,7 @@ import { resetTestEvents, cleanupData, logEventsDataToTheConsoleTable, + getAllTestEvents, } from "./events/data"; import { getKeypressDurations, @@ -1013,6 +1014,19 @@ function compareCompletedEvents( continue; } + if (key === "wpm" || key === "rawWpm") { + val1 = Numbers.roundTo2(val1 as number); + val2 = Numbers.roundTo2(val2 as number); + const diff = Numbers.roundTo2(Math.abs(val1 - val2)); + if (diff <= 0.01) { + console.debug(`Completed event match on key ${key}:`, val1); + } else { + notMatching.push(`${key} (off by ${diff})`); + mismatchedKeys.push(key); + console.error(`Completed event mismatch on key ${key}:`, val1, val2); + } + } + // if (key === "chartData") { // val1 = { // //@ts-expect-error temp @@ -1294,7 +1308,11 @@ function compareCompletedEvents( difficulty: ce.difficulty, duration: ce.testDuration, funboxes: getActiveFunboxNames().join(","), - version: 8, + version: 10, + data: { + words: TestWords.words.list.join(" "), + events: getAllTestEvents(), + }, // ce: ce as Record, // ce2: ce2 as Record, }, diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index 45d71741aa92..618e77fcd80a 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -75,7 +75,11 @@ export const ReportCompletedEventMismatchRequestSchema = z.object({ difficulty: DifficultySchema.optional(), duration: z.number().max(200).optional(), funboxes: z.string().max(100).optional(), - version: z.literal(8), + version: z.literal(10), + data: z.object({ + words: z.string().max(10000), + events: z.array(z.record(z.unknown())), + }), // ce: z.record(z.unknown()), // ce2: z.record(z.unknown()), });