Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/api/controllers/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export async function reportCompletedEventMismatch(
difficulty,
duration,
funboxes,
version,
} = req.body;
// Logger.warning(
// `Completed event mismatch for uid ${uid}: ${notMatching.join(", ")}`,
Expand All @@ -217,6 +218,7 @@ export async function reportCompletedEventMismatch(
difficulty,
duration,
funboxes,
version,
},
uid,
);
Expand Down
37 changes: 37 additions & 0 deletions frontend/__tests__/test/events/stats.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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", () => {
Expand Down
156 changes: 77 additions & 79 deletions frontend/src/ts/test/events/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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<number, InputEvent[]>,
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, InputEvent[]>,
): 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 {
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 16 additions & 7 deletions frontend/src/ts/test/test-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
}

Expand Down Expand Up @@ -1214,6 +1222,7 @@ function compareCompletedEvents(
difficulty: ce.difficulty,
duration: ce.testDuration,
funboxes: getActiveFunboxNames().join(","),
version: 1,
// ce: ce as Record<string, unknown>,
// ce2: ce2 as Record<string, unknown>,
},
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
});
Expand Down
Loading