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
14 changes: 14 additions & 0 deletions frontend/__tests__/test/events/data.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ describe("data.ts", () => {
expect(inputs).toHaveLength(1);
});

it("strips undefined values from eventData", () => {
logTestEvent("input", 1100, {
charIndex: 0,
wordIndex: 0,
inputType: "deleteWordBackward",
inputValue: "",
clearedNextWord: undefined,
} as unknown as InputEventData);

const stored = getAllTestEvents()[0]?.data as Record<string, unknown>;
expect("clearedNextWord" in stored).toBe(false);
expect(stored["inputValue"]).toBe("");
});

it("caches getAllTestEvents and invalidates on new event", () => {
logTestEvent("timer", 1100, timerData("start", 0));
const first = getAllTestEvents();
Expand Down
105 changes: 105 additions & 0 deletions frontend/__tests__/test/events/stats.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
getKeypressDurations,
getKeypressesPerSecond,
getChars,
getInputHistory,
getWpmHistory,
forceReleaseAllKeys,
__testing as statsTesting,
Expand Down Expand Up @@ -511,6 +512,110 @@ describe("stats.ts", () => {
});
});

describe("getInputHistory", () => {
it("treats abandoned word as empty when Firefox Ctrl+Backspace ate the sentinel", () => {
// Firefox groups whitespace + non-word punctuation as one delete run.
// Sequence: type "=ri" at word 1, Ctrl+Backspace twice. The first delete
// leaves "=" (browser deletes "ri" only). The second deletes the
// sentinel + "=" together, which monkeytype interprets as crossing the
// word boundary → goToPreviousWord. Word 1 is abandoned with leftover
// "=" residue in its event stream; its final state should still be "".
TestWords.list.push("hello", "leave");

logTestEvent("timer", 1000, timer("start", 0));
logTestEvent(
"input",
1100,
input({ wordIndex: 0, data: "h", charIndex: 0 }),
);
logTestEvent(
"input",
1110,
input({ wordIndex: 0, data: "e", charIndex: 1 }),
);
logTestEvent(
"input",
1120,
input({ wordIndex: 0, data: "l", charIndex: 2 }),
);
logTestEvent(
"input",
1130,
input({ wordIndex: 0, data: "l", charIndex: 3 }),
);
logTestEvent(
"input",
1140,
input({ wordIndex: 0, data: "o", charIndex: 4 }),
);
logTestEvent(
"input",
1150,
input({
wordIndex: 0,
data: " ",
charIndex: 5,
commitsWord: true,
}),
);

logTestEvent(
"input",
1200,
input({
wordIndex: 1,
data: "=",
correct: false,
charIndex: 0,
}),
);
logTestEvent(
"input",
1210,
input({
wordIndex: 1,
data: "r",
correct: false,
charIndex: 1,
}),
);
logTestEvent(
"input",
1220,
input({
wordIndex: 1,
data: "i",
correct: false,
charIndex: 2,
}),
);

// first Ctrl+Backspace: "=ri" → "="
logTestEvent("input", 1300, {
wordIndex: 1,
charIndex: 3,
inputType: "deleteWordBackward",
inputValue: "=",
} as InputEventData);

// second Ctrl+Backspace: Firefox ate sentinel + "=" → goToPreviousWord;
// clearedNextWord marks word 1 (= wordIndex + 1) as abandoned
logTestEvent("input", 1400, {
wordIndex: 0,
charIndex: 0,
inputType: "deleteWordBackward",
inputValue: "",
clearedNextWord: true,
} as InputEventData);

logTestEvent("timer", 5000, timer("end", 4));

const history = getInputHistory();
expect(history[0]).toBe("");
expect(history[1]).toBe("");
});
});

describe("getAccuracy", () => {
it("calculates correct/incorrect/percentage", () => {
logTestEvent("input", 1100, input());
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/ts/input/handlers/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ export function onDelete(inputType: DeleteInputType, now: number): void {

//normal backspace
if (realInputValue === "") {
goToPreviousWord(inputType);
// if the input is NOT empty, that means the ctrl backspace deleted more than just the fake space (THANKS FIREFOX)
// which means we need to force update the current word element when we move back
goToPreviousWord(inputType, inputBeforeDelete !== "");

// Record the resulting state of the destination word
const postNavInputValue = getInputElementValue().inputValue;
Expand All @@ -72,6 +74,7 @@ export function onDelete(inputType: DeleteInputType, now: number): void {
wordIndex: activeWordIndex,
charIndex: postNavInputValue.length,
inputValue: postNavInputValue,
...(inputBeforeDelete !== "" ? { clearedNextWord: true } : {}),
});
} else {
// Delete within current word
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/ts/test/events/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export function logTestEvent(

now = roundTo2(now);

//strip undefined values from eventData
eventData = Object.fromEntries(
Object.entries(eventData).filter(([_, v]) => v !== undefined),
) as TestEventData;

if (type === "keydown") {
const data = eventData as KeydownEventData;
const code = data.code as Keycode | "NoCode";
Expand Down
25 changes: 22 additions & 3 deletions frontend/src/ts/test/events/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,9 +348,28 @@ export function getInputHistory(): string[] {
const eventsPerWordIndex = getInputEventsPerWord();
const history: string[] = [];

for (const events of eventsPerWordIndex.values()) {
const simulatedInput = getInputFromDom(events);
history.push(simulatedInput);
for (const [wordIndex, events] of eventsPerWordIndex) {
const lastEvent = events[events.length - 1];
if (lastEvent === undefined) {
history.push("");
continue;
}

// THANKS FIREFOX FOR THIS MESS
// A word is abandoned if the regression destination event — which
// lives in the previous word's bucket — carries clearedNextWord.
// Happens when Ctrl+Backspace eats the sentinel + non-word residue as
// one run; the residue stays as this word's last inputValue but its
// real final state is "".
const previousWord = eventsPerWordIndex.get(wordIndex - 1) ?? [];
const abandoned = previousWord.some(
(e) =>
e.testMs > lastEvent.testMs &&
"clearedNextWord" in e.data &&
e.data.clearedNextWord === true,
);

history.push(abandoned ? "" : getInputFromDom(events));
}

return history;
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/ts/test/events/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ export type InputEventData =
})
| (BaseInputEventData & {
inputType: DeleteInputType;
// true on the destination event of a regression that crossed back
// over a word with leftover content (e.g. Firefox Ctrl+Backspace
// eating sentinel + non-word residue). The cleared word is
// wordIndex + 1.
clearedNextWord?: true;
});

export type CompositionTestEvent = EventProps<
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/ts/test/test-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1341,7 +1341,7 @@ function compareCompletedEvents(
difficulty: ce.difficulty,
duration: ce.testDuration,
funboxes: getActiveFunboxNames().join(","),
version: 22,
version: 23,
data: {
words: TestWords.words.list.join(" "),
events: getAllTestEvents(),
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/ts/test/test-timer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const newTimer = createTimer({
onLoop: () => {
const now = performance.now();

const drift = Math.abs(1000 - (now - lastLoop));
const drift = Numbers.roundTo2(Math.abs(1000 - (now - lastLoop)));
checkIfTimerIsSlow(drift);
lastLoop = now;
timerStep();
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(22),
version: z.literal(23),
data: z.object({
words: z.string().max(10000),
events: z.array(z.record(z.unknown())),
Expand Down
Loading