diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 28fdf24b8aba..c119b5e5a969 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -199,6 +199,7 @@ export async function reportCompletedEventMismatch( difficulty, duration, funboxes, + version, } = req.body; // Logger.warning( // `Completed event mismatch for uid ${uid}: ${notMatching.join(", ")}`, @@ -217,6 +218,7 @@ export async function reportCompletedEventMismatch( difficulty, duration, funboxes, + version, }, uid, ); diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index bffcc1fbafee..4f584e34e305 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -8,6 +8,7 @@ vi.mock("../../../src/ts/test/test-state", () => ({ activeWordIndex: 0, bailedOut: false, resultCalculating: false, + koreanStatus: false, })); vi.mock("../../../src/ts/config/store", () => ({ @@ -180,6 +181,28 @@ describe("stats.ts", () => { expect(statsTesting.getTimerBoundaries(events)).toEqual([1000]); }); + it("includes end boundary when gap rounds to 0.5s via roundTo2", () => { + // 496ms gap: roundTo2(0.496) = 0.5 so this should be treated as a 0.5s remainder + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 2496, timer("end", 1)); + + const events = getAllTestEvents(); + // end testMs=1496, last step testMs=1000 — gap 496ms rounds to 0.50s + expect(statsTesting.getTimerBoundaries(events)).toEqual([1000, 1496]); + }); + + it("skips end boundary when gap rounds below 0.5s via roundTo2", () => { + // 494ms gap: roundTo2(0.494) = 0.49 so this should not be an extra boundary + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("timer", 2494, timer("end", 1)); + + const events = getAllTestEvents(); + // end testMs=1494, last step testMs=1000 — gap 494ms rounds to 0.49s + expect(statsTesting.getTimerBoundaries(events)).toEqual([1000]); + }); + it("excludes short trailing interval (<500ms) for non-round test duration", () => { // 1.35s test: step at 1s, end at 1.35s — remainder 350ms < 500 logTestEvent("timer", 1000, timer("start", 0)); @@ -501,6 +524,20 @@ describe("stats.ts", () => { logTestEvent("input", 1200, input()); expect(getKeypressesPerSecond()).toEqual([]); }); + + it("counts keypresses in last partial second when gap rounds to 0.5s", () => { + // mirrors the totalKeypressCountHistory mismatch: legacy pushes for roundTo2 >= 0.5, + // but the old boundary check (>= 500ms) skips a 496ms tail + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent("input", 1200, input()); // first second + logTestEvent("timer", 2000, timer("step", 1)); + logTestEvent("input", 2200, input({ charIndex: 1 })); // 496ms tail + logTestEvent("input", 2400, input({ charIndex: 2 })); + logTestEvent("timer", 2496, timer("end", 1)); + + // gap = 496ms, roundTo2(0.496) = 0.5 → end boundary added → [1, 2] + expect(getKeypressesPerSecond()).toEqual([1, 2]); + }); }); describe("getTargetWord", () => { diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index aed9388f442a..8972823aadd0 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -10,12 +10,13 @@ import * as TestWords from "../../test/test-words"; import { CharCounts, countChars, getLastChar } from "../../utils/strings"; import * as CustomText from "../../test/custom-text"; import { getInputFromDom } from "./helpers"; -import { activeWordIndex, bailedOut } from "../test-state"; +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 { Config } from "../../config/store"; import { isFunboxActiveWithProperty } from "../funbox/list"; +import Hangul from "hangul-js"; function getTimerBoundaries(events: TestEvent[]): number[] { const boundaries: number[] = []; @@ -47,7 +48,11 @@ function getTimerBoundaries(events: TestEvent[]): number[] { if (endMs !== undefined) { const last = boundaries[boundaries.length - 1]; - if (endMs - (last ?? 0) >= 500) { + // Must match the legacy condition: Math.round(roundTo2(testSeconds) % 1) >= 0.5. + // A naive ">= 500ms" check disagrees when the gap is in [495ms, 500ms) — roundTo2 + // rounds that fraction up to 0.50s and the legacy system pushes an extra bucket, + // but a raw millisecond comparison would skip the boundary. + if (roundTo2((endMs - (last ?? 0)) / 1000) >= 0.5) { boundaries.push(endMs); } } @@ -255,56 +260,86 @@ function getTargetWord( } } -export function getChars(): CharCounts { - const eventsPerWordIndex = getInputEventsPerWord(); - const isTimedTest = - Config.mode === "time" || - (Config.mode === "custom" && CustomText.getLimit().mode === "time"); - const shouldCountPartialLastWord = isTimedTest; - - let allCorrect = 0; - let correctWord = 0; - let incorrect = 0; - let extra = 0; - let missed = 0; +function countCharsForWords( + eventsPerWord: Map, + lastWordIndex: number, + shouldCountPartialLastWord: boolean, +): CharCounts { + const acc: CharCounts = { + allCorrect: 0, + correctWord: 0, + incorrect: 0, + extra: 0, + missed: 0, + }; - for (const [wordIndex, events] of eventsPerWordIndex.entries()) { - const lastWord = wordIndex === activeWordIndex; + for (const [wordIndex, events] of eventsPerWord) { + const lastWord = wordIndex === lastWordIndex; let simulatedInput = getInputFromDom(events); - + if (koreanStatus) { + simulatedInput = Hangul.disassemble(simulatedInput).join(""); + } if (lastWord) { - //remove trailing space for last word simulatedInput = simulatedInput.trimEnd(); } - const targetWord = getTargetWord(wordIndex, simulatedInput, lastWord); + let targetWord = getTargetWord(wordIndex, simulatedInput, lastWord); + if (koreanStatus) { + targetWord = Hangul.disassemble(targetWord).join(""); + } - const charCounts = countChars( + const c = countChars( simulatedInput, targetWord, lastWord, shouldCountPartialLastWord, ); + acc.allCorrect += c.allCorrect; + acc.correctWord += c.correctWord; + acc.incorrect += c.incorrect; + acc.extra += c.extra; + acc.missed += c.missed; - allCorrect += charCounts.allCorrect; - correctWord += charCounts.correctWord; - incorrect += charCounts.incorrect; - extra += charCounts.extra; - missed += charCounts.missed; + if (lastWord) break; + } - if (lastWord) { - break; + return acc; +} + +function inferActiveWordIndex( + eventsPerWord: Map, +): number { + let maxWordIndex = -1; + let lastWordEvents: InputEvent[] | undefined; + for (const [k, wordEvents] of eventsPerWord) { + if (getInputFromDom(wordEvents).length > 0 && k > maxWordIndex) { + maxWordIndex = k; + lastWordEvents = wordEvents; } } + if (lastWordEvents === undefined) return 0; + const lastEvt = lastWordEvents[lastWordEvents.length - 1]; + // committed trailing space → cursor advanced to the next word + if ( + lastEvt !== undefined && + lastEvt.data.inputType === "insertText" && + lastEvt.data.data === " " + ) { + return maxWordIndex + 1; + } + return maxWordIndex; +} - return { - allCorrect: allCorrect, - correctWord: correctWord, - incorrect: incorrect, - extra: extra, - missed: missed, - }; +export function getChars(): CharCounts { + const isTimedTest = + Config.mode === "time" || + (Config.mode === "custom" && CustomText.getLimit().mode === "time"); + return countCharsForWords( + getInputEventsPerWord(), + activeWordIndex, + isTimedTest, + ); } export function getInputForWord(wordIndex: number): string { @@ -404,54 +439,17 @@ export function getErrorCountHistory(): number[] { export function getWpmHistory(): number[] { const events = getAllTestEvents(); - const timerBoundaries = getTimerBoundaries(events); const wpmHistory: number[] = []; - for (const boundary of timerBoundaries) { + for (const boundary of getTimerBoundaries(events)) { 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 = getTargetWord(wordIndex, trimmed, lastWord); - totalCorrect += countChars( - trimmed, - targetWord, - lastWord, - true, - ).correctWord; - } - - const durationSeconds = boundary / 1000; - wpmHistory.push(Math.round(calculateWpm(totalCorrect, durationSeconds))); + const lastWordIndex = inferActiveWordIndex(eventsPerWord); + const { correctWord } = countCharsForWords( + eventsPerWord, + lastWordIndex, + true, + ); + wpmHistory.push(Math.round(calculateWpm(correctWord, boundary / 1000))); } return wpmHistory; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index c3685172aa69..18d51ac36017 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1114,13 +1114,21 @@ function compareCompletedEvents( 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, - ); + if (a.length !== b.length) { + notMatching.push(`keypressCountHistory (length differs)`); + mismatchedKeys.push("keypressCountHistory_length"); + console.error( + `Completed event length mismatch on key keypressCountHistory: ${a.length} vs ${b.length}`, + ); + } else { + notMatching.push(`keypressCountHistory (values differ)`); + mismatchedKeys.push("keypressCountHistory"); + console.error( + `Completed event mismatch on key keypressCountHistory:`, + a, + b, + ); + } } } @@ -1214,6 +1222,7 @@ function compareCompletedEvents( difficulty: ce.difficulty, duration: ce.testDuration, funboxes: getActiveFunboxNames().join(","), + version: 1, // ce: ce as Record, // ce2: ce2 as Record, }, diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index 1c4ff5900d3d..aa4dfa0ff2c6 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -75,6 +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(1), // ce: z.record(z.unknown()), // ce2: z.record(z.unknown()), });