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 @@ -200,6 +200,7 @@ export async function reportCompletedEventMismatch(
duration,
funboxes,
version,
data,
} = req.body;
// Logger.warning(
// `Completed event mismatch for uid ${uid}: ${notMatching.join(", ")}`,
Expand All @@ -219,6 +220,7 @@ export async function reportCompletedEventMismatch(
duration,
funboxes,
version,
data,
},
uid,
);
Expand Down
30 changes: 8 additions & 22 deletions frontend/__tests__/test/events/data.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down
40 changes: 29 additions & 11 deletions frontend/__tests__/test/events/stats.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/ts/input/handlers/insert-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,11 @@ export async function onInsertText(options: OnInsertTextParams): Promise<void> {
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
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/ts/input/handlers/keydown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,10 @@ export async function onKeydown(event: KeyboardEvent): Promise<void> {

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
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/ts/input/handlers/keyup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ export async function onKeyup(event: KeyboardEvent): Promise<void> {
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
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/ts/input/listeners/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
45 changes: 25 additions & 20 deletions frontend/src/ts/test/events/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
KeydownEventData,
KeyupEvent,
KeyupEventData,
TestEvent,
InputEventNoMs,
TestEventData,
TestEventNoMs,
TestEventType,
TimerEvent,
TimerEventData,
Expand All @@ -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<
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}
Expand Down Expand Up @@ -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",
);
}

Expand All @@ -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) {
Expand All @@ -335,8 +340,8 @@ export function getInputEventsForWord(wordIndex: number): InputEvent[] {
export function getInputEventsPerWord(
startMs?: number,
testMsLimit?: number,
): Map<number, InputEvent[]> {
let eventsPerWordIndex: Map<number, InputEvent[]> = new Map();
): Map<number, InputEventNoMs[]> {
let eventsPerWordIndex: Map<number, InputEventNoMs[]> = new Map();
const events = getAllTestEvents();
for (const event of events) {
if (event.type !== "input") {
Expand Down
16 changes: 8 additions & 8 deletions frontend/src/ts/test/events/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<Keycode | "NoCode">([
"NumpadMultiply",
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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;
}
Expand All @@ -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 (
Expand Down
Loading
Loading