diff --git a/frontend/src/ts/components/modals/account-settings/UpdatePasswordModal.tsx b/frontend/src/ts/components/modals/account-settings/UpdatePasswordModal.tsx index e582218376a1..4344575551ac 100644 --- a/frontend/src/ts/components/modals/account-settings/UpdatePasswordModal.tsx +++ b/frontend/src/ts/components/modals/account-settings/UpdatePasswordModal.tsx @@ -20,7 +20,7 @@ export function showUpdatePasswordModal(): void { showSimpleModal({ title: "Update password", schema: z.object({ - previousPass: getPasswordSchema(), + previousPass: z.string().min(1, "Current password is required"), newPassword: getPasswordSchema(), newPassConfirm: getPasswordSchema(), }), diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index a7594f6e220c..0bd68b7d37b1 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -18,7 +18,7 @@ import { } from "./types"; import { keysToTrack } from "./helpers"; import { Keycode } from "../../constants/keys"; -import { mean, roundTo2 } from "@monkeytype/util/numbers"; +import { isSafeNumber, mean, roundTo2 } from "@monkeytype/util/numbers"; import { bailedOut, koreanStatus, resultCalculating } from "../test-state"; import * as TestWords from "../test-words"; import { Config } from "../../config/store"; @@ -78,6 +78,10 @@ export function logTestEvent( now = roundTo2(now); + if (!isSafeNumber(now)) { + throw new Error(`Invalid timestamp: ${now}`); + } + //strip undefined values from eventData eventData = Object.fromEntries( Object.entries(eventData).filter(([_, v]) => v !== undefined), @@ -188,84 +192,51 @@ function invalidateCache(): void { } export function cleanupData(): void { - invalidateCache(); - getAllTestEvents(); - - if (cachedAllEvents === undefined) { - throw new Error( - "cachedAllEvents should not be undefined after getAllTestEvents", - ); - } - - //remove all pre-start keydown/keyup events except the last keydown - const timerStartIndex = cachedAllEvents.findIndex( - (e) => e.type === "timer" && e.data.event === "start", - ); - if (timerStartIndex !== -1) { - // find the last keydown before timer start - let lastPreStartKeydownIndex = -1; - for (let i = timerStartIndex - 1; i >= 0; i--) { - if (cachedAllEvents[i]?.type === "keydown") { - lastPreStartKeydownIndex = i; - break; - } + const timerStart = timerEvents.find((e) => e.data.event === "start"); + const timerEnd = timerEvents.find((e) => e.data.event === "end"); + + if (timerStart !== undefined) { + // keep only the last pre-start keydown; drop all pre-start keyups + let lastPreStartKeydown: KeydownEvent | undefined; + for (const e of keydownEvents) { + if (e.ms < timerStart.ms) lastPreStartKeydown = e; + else break; } - cachedAllEvents = cachedAllEvents.filter((e, index) => { - if (index >= timerStartIndex) return true; - if (e.type === "keydown") return index === lastPreStartKeydownIndex; - if (e.type === "keyup") return false; - return true; - }); - } - - //remove all input events after timer end - const timerEndIndex = cachedAllEvents.findIndex( - (e) => e.type === "timer" && e.data.event === "end", - ); - if (timerEndIndex !== -1) { - cachedAllEvents = cachedAllEvents.filter( - (e, index) => !(e.type === "input" && index > timerEndIndex), + keydownEvents = keydownEvents.filter( + (e) => e.ms >= timerStart.ms || e === lastPreStartKeydown, ); + keyupEvents = keyupEvents.filter((e) => e.ms >= timerStart.ms); } - //remove keydowns after timer end, and their associated keyups - if (timerEndIndex !== -1) { - const keydownsAfterTimerEnd = new Set( - cachedAllEvents - .filter((e, index) => e.type === "keydown" && index > timerEndIndex) - .map((e) => (e.data as KeydownEventData).code), + if (timerEnd !== undefined) { + inputEvents = inputEvents.filter((e) => e.ms <= timerEnd.ms); + const postEndKeydownCodes = new Set( + keydownEvents.filter((e) => e.ms > timerEnd.ms).map((e) => e.data.code), + ); + keydownEvents = keydownEvents.filter((e) => e.ms <= timerEnd.ms); + keyupEvents = keyupEvents.filter( + (e) => e.ms <= timerEnd.ms || !postEndKeydownCodes.has(e.data.code), ); - cachedAllEvents = cachedAllEvents.filter((e, index) => { - if (index <= timerEndIndex) return true; - if (e.type === "keydown") return false; - if (e.type === "keyup") { - return !keydownsAfterTimerEnd.has(e.data.code); - } - return true; - }); } - // sync source arrays back from cleaned cache - keydownEvents = cachedAllEvents.filter( - (e): e is KeydownEvent => e.type === "keydown", - ); - keyupEvents = cachedAllEvents.filter( - (e): e is KeyupEvent => e.type === "keyup", - ); - timerEvents = cachedAllEvents.filter( - (e): e is TimerEvent => e.type === "timer", - ); - inputEvents = cachedAllEvents.filter( - (e): e is InputEvent => e.type === "input", - ); - compositionEvents = cachedAllEvents.filter( - (e): e is CompositionTestEvent => e.type === "composition", - ); + invalidateCache(); } export function getAllTestEvents(): TestEventNoMs[] { if (cachedAllEvents !== undefined) return cachedAllEvents; + const total = + keydownEvents.length + + keyupEvents.length + + timerEvents.length + + inputEvents.length + + compositionEvents.length; + + if (total === 0) { + cachedAllEvents = []; + return cachedAllEvents; + } + const firstEventMs = Math.min( ...[ keydownEvents[0]?.ms, @@ -277,14 +248,11 @@ export function getAllTestEvents(): TestEventNoMs[] { ); const startEventMs = - timerEvents.find((e) => e.data.event === "start")?.ms ?? firstEventMs ?? 0; + timerEvents.find((e) => e.data.event === "start")?.ms ?? firstEventMs; - const total = - keydownEvents.length + - keyupEvents.length + - timerEvents.length + - inputEvents.length + - compositionEvents.length; + if (!isSafeNumber(startEventMs)) { + throw new Error(`Invalid startEventMs: ${startEventMs}`); + } const merged = new Array(total); let p = 0; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 7c6b3c70fc13..87bc308b147c 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -947,7 +947,10 @@ function compareCompletedEvents( key === "timestamp" || key === "keyDuration" || key === "keySpacing" || - key === "chartData" + key === "chartData" || + key === "consistency" || + key === "keyConsistency" || + key === "keyOverlap" ) { continue; } @@ -1024,6 +1027,7 @@ function compareCompletedEvents( continue; } + ///@ts-expect-error temp if (key === "keyOverlap") { val1 = Numbers.roundTo2(val1 as number); val2 = Numbers.roundTo2(val2 as number); @@ -1274,9 +1278,31 @@ function compareCompletedEvents( if (a.length === b.length && a.every((val, i) => val === b[i])) { console.debug(`Completed event match on rawHistory:`, a); } else { - notMatching.push(`rawHistory (values differ)`); + const len = Math.min(a.length, b.length); + const diffs: number[] = []; + for (let i = 0; i < len; i++) { + const av = a[i] as number; + const bv = b[i] as number; + const denom = Math.abs(av); + if (denom === 0) { + if (bv !== 0) diffs.push(100); + continue; + } + diffs.push((Math.abs(av - bv) / denom) * 100); + } + const avg = diffs.length + ? diffs.reduce((acc, v) => acc + v, 0) / diffs.length + : 0; + const avgRounded = Numbers.roundTo2(avg); + notMatching.push( + `rawHistory (avg ${avgRounded}% difference): ${JSON.stringify(a)} vs ${JSON.stringify(b)}`, + ); mismatchedKeys.push("rawHistory"); - console.error(`Completed event mismatch on rawHistory:`, a, b); + console.error( + `Completed event mismatch on rawHistory (avg ${avgRounded}% difference):`, + a, + b, + ); } } @@ -1287,9 +1313,31 @@ function compareCompletedEvents( if (a.length === b.length && a.every((val, i) => val === b[i])) { console.debug(`Completed event match on chartData.wpm:`, a); } else { - notMatching.push(`chartData.wpm (values differ)`); + const len = Math.min(a.length, b.length); + const diffs: number[] = []; + for (let i = 0; i < len; i++) { + const av = a[i] as number; + const bv = b[i] as number; + const denom = Math.abs(av); + if (denom === 0) { + if (bv !== 0) diffs.push(100); + continue; + } + diffs.push((Math.abs(av - bv) / denom) * 100); + } + const avg = diffs.length + ? diffs.reduce((acc, v) => acc + v, 0) / diffs.length + : 0; + const avgRounded = Numbers.roundTo2(avg); + notMatching.push( + `chartData.wpm (avg ${avgRounded}% difference): ${JSON.stringify(a)} vs ${JSON.stringify(b)}`, + ); mismatchedKeys.push("chartData.wpm"); - console.error(`Completed event mismatch on chartData.wpm:`, a, b); + console.error( + `Completed event mismatch on chartData.wpm (avg ${avgRounded}% difference):`, + a, + b, + ); } } } @@ -1386,7 +1434,7 @@ function compareCompletedEvents( difficulty: ce.difficulty, duration: ce.testDuration, funboxes: getActiveFunboxNames().join(","), - version: 28, + version: 29, eventLog, // ce: ce as Record, // ce2: ce2 as Record, diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index 553dbdb4b486..2a818e0fe5e6 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -75,7 +75,7 @@ export const ReportCompletedEventMismatchRequestSchema = z.object({ difficulty: DifficultySchema.optional(), duration: z.number().max(200).optional(), funboxes: z.string().max(100).optional(), - version: z.literal(28), + version: z.literal(29), eventLog: z.object({ version: z.number(), context: z.record(z.unknown()),