diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 02b332b05662..e2d61d6866a9 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -200,7 +200,7 @@ export async function reportCompletedEventMismatch( duration, funboxes, version, - data, + eventLog, } = req.body; // Logger.warning( // `Completed event mismatch for uid ${uid}: ${notMatching.join(", ")}`, @@ -220,7 +220,7 @@ export async function reportCompletedEventMismatch( duration, funboxes, version, - data, + eventLog, }, uid, ); diff --git a/frontend/__tests__/test/events/data.spec.ts b/frontend/__tests__/test/events/data.spec.ts index 97c1a57a2c60..f419634b5d4f 100644 --- a/frontend/__tests__/test/events/data.spec.ts +++ b/frontend/__tests__/test/events/data.spec.ts @@ -7,7 +7,6 @@ vi.mock("../../../src/ts/test/test-stats", () => ({ import { logTestEvent, getAllTestEvents, - getEventsPerWord, cleanupData, resetTestEvents, __testing, @@ -22,6 +21,7 @@ import type { TimerEventData, } from "../../../src/ts/test/events/types"; import { Keycode } from "../../../src/ts/constants/keys"; +import { getEventsPerWord } from "../../../src/ts/test/events/helpers"; function keyDown(code: Keycode | "NoCode" = "KeyA"): KeydownEventData { return { code }; @@ -251,7 +251,7 @@ describe("data.ts", () => { logTestEvent("input", 1020, inputData({ wordIndex: 0, charIndex: 1 })); logTestEvent("input", 1030, inputData({ wordIndex: 1, charIndex: 0 })); - const perWord = getEventsPerWord(); + const perWord = getEventsPerWord(getAllTestEvents()); expect(perWord.get(0)).toHaveLength(2); expect(perWord.get(1)).toHaveLength(1); }); @@ -266,7 +266,7 @@ describe("data.ts", () => { wordIndex: 0, }); - const perWord = getEventsPerWord(); + const perWord = getEventsPerWord(getAllTestEvents()); expect(perWord.get(0)).toHaveLength(3); }); @@ -277,7 +277,7 @@ describe("data.ts", () => { inputType: "deleteContentBackward", } as InputEventData); - const perWord = getEventsPerWord(); + const perWord = getEventsPerWord(getAllTestEvents()); expect(perWord.get(0)).toHaveLength(1); }); @@ -285,7 +285,7 @@ describe("data.ts", () => { logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); logTestEvent("input", 1100, inputData({ wordIndex: 0, charIndex: 1 })); - const perWord = getEventsPerWord(undefined, 50); + const perWord = getEventsPerWord(getAllTestEvents(), undefined, 50); expect(perWord.get(0)).toHaveLength(1); }); @@ -293,7 +293,7 @@ describe("data.ts", () => { logTestEvent("input", 1010, inputData({ wordIndex: 0, charIndex: 0 })); logTestEvent("input", 1100, inputData({ wordIndex: 0, charIndex: 1 })); - const perWord = getEventsPerWord(50); + const perWord = getEventsPerWord(getAllTestEvents(), 50); expect(perWord.get(0)).toHaveLength(1); const first = perWord.get(0)?.[0]; expect(first?.type === "input" && first.data.charIndex).toBe(1); diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 8a8ab0044f63..9779372e2821 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -12,7 +12,7 @@ vi.mock("../../../src/ts/test/test-state", () => ({ })); vi.mock("../../../src/ts/config/store", () => ({ - Config: { mode: "words", funbox: [] as string[] }, + Config: { mode: "words", funbox: [] as string[], words: 25, time: 0 }, getConfig: {}, })); @@ -37,13 +37,19 @@ vi.mock("../../../src/ts/test/custom-text", () => ({ getLimit: () => customTextLimit, })); +vi.mock("../../../src/ts/states/test", () => ({ + getCurrentQuote: () => null, +})); + import { logTestEvent, resetTestEvents, getAllTestEvents, cleanupData, + buildEventLog, __testing, } from "../../../src/ts/test/events/data"; +import { getEventsPerWord } from "../../../src/ts/test/events/helpers"; import { getStartToFirstKeypressMs, getLastKeypressToEndMs, @@ -157,6 +163,8 @@ describe("stats.ts", () => { __testing.resetPressedKeys(); (Config as { mode: string }).mode = "words"; (Config as { funbox: string[] }).funbox = []; + (Config as { words: number }).words = 25; + (Config as { time: number }).time = 0; (TestState as { activeWordIndex: number }).activeWordIndex = 0; TestWords.list.length = 0; inputPerWord.clear(); @@ -170,9 +178,9 @@ describe("stats.ts", () => { logTestEvent("timer", 4000, timer("step", 3)); logTestEvent("timer", 4000, timer("end", 3)); - const events = getAllTestEvents(); + const eventLog = buildEventLog(); // end testMs=3000, last step testMs=3000 — gap is 0 < 500, end skipped - expect(statsTesting.getTimerBoundaries(events)).toEqual([ + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([ 1000, 2000, 3000, ]); }); @@ -182,9 +190,9 @@ describe("stats.ts", () => { logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2500, timer("end", 1)); - const events = getAllTestEvents(); + const eventLog = buildEventLog(); // endMs=1500 → 1500%1000=500ms → roundTo2(0.5)=0.5 → boundary added - expect(statsTesting.getTimerBoundaries(events)).toEqual([1000, 1500]); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([1000, 1500]); }); it("skips end when too close to last step", () => { @@ -192,9 +200,9 @@ describe("stats.ts", () => { logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2400, timer("end", 1)); - const events = getAllTestEvents(); + const eventLog = buildEventLog(); // end at testMs 1400, last step at testMs 1000 — gap is 400 < 500 - expect(statsTesting.getTimerBoundaries(events)).toEqual([1000]); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([1000]); }); it("includes end boundary when endMs % 1000 rounds to 0.5s", () => { @@ -203,8 +211,8 @@ describe("stats.ts", () => { logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2496, timer("end", 1)); - const events = getAllTestEvents(); - expect(statsTesting.getTimerBoundaries(events)).toEqual([1000, 1496]); + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([1000, 1496]); }); it("skips end boundary when endMs % 1000 rounds below 0.5s", () => { @@ -213,8 +221,8 @@ describe("stats.ts", () => { logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2494, timer("end", 1)); - const events = getAllTestEvents(); - expect(statsTesting.getTimerBoundaries(events)).toEqual([1000]); + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([1000]); }); it("skips end boundary for .49 test even when step fires slightly early (drift)", () => { @@ -225,8 +233,8 @@ describe("stats.ts", () => { logTestEvent("timer", 1995, timer("step", 1)); logTestEvent("timer", 2490, timer("end", 1)); - const events = getAllTestEvents(); - expect(statsTesting.getTimerBoundaries(events)).toEqual([995]); + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([995]); }); it("includes end boundary for .99 test even when step fires late (drift)", () => { @@ -237,8 +245,8 @@ describe("stats.ts", () => { logTestEvent("timer", 2510, timer("step", 1)); logTestEvent("timer", 2990, timer("end", 1)); - const events = getAllTestEvents(); - expect(statsTesting.getTimerBoundaries(events)).toEqual([1510, 1990]); + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([1510, 1990]); }); it("excludes short trailing interval (<500ms) for non-round test duration", () => { @@ -247,9 +255,9 @@ describe("stats.ts", () => { logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2350, timer("end", 1)); - const events = getAllTestEvents(); + const eventLog = buildEventLog(); // end testMs=1350, last step testMs=1000 — gap is 350 < 500, end skipped - expect(statsTesting.getTimerBoundaries(events)).toEqual([1000]); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([1000]); }); it("excludes short trailing interval (<500ms) for sub one second test duration", () => { @@ -257,16 +265,16 @@ describe("stats.ts", () => { logTestEvent("timer", 1000, timer("start", 0)); logTestEvent("timer", 1350, timer("end", 0)); - const events = getAllTestEvents(); + const eventLog = buildEventLog(); // end testMs=1350, last step testMs=1000 — gap is 350 < 500, end skipped - expect(statsTesting.getTimerBoundaries(events)).toEqual([]); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([]); }); it("returns empty when no timer events", () => { logTestEvent("keydown", 1000, keyDown()); - const events = getAllTestEvents(); - expect(statsTesting.getTimerBoundaries(events)).toEqual([]); + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaries(eventLog)).toEqual([]); }); it("adjusts end in zen mode by removing trailing afk", () => { @@ -279,8 +287,8 @@ describe("stats.ts", () => { // last keypress at testMs 500, end at testMs 4000 → lkte = 3500 logTestEvent("timer", 5000, timer("end", 4)); - const events = getAllTestEvents(); - const boundaries = statsTesting.getTimerBoundaries(events); + const eventLog = buildEventLog(); + const boundaries = statsTesting.getTimerBoundaries(eventLog); // adjusted end = 4000 - 3500 = 500, steps at 1000 and 2000 are past it expect(boundaries).toEqual([500]); }); @@ -295,9 +303,9 @@ describe("stats.ts", () => { } logTestEvent("timer", 19997, timer("end", 20)); - const events = getAllTestEvents(); + const eventLog = buildEventLog(); // 20 step boundaries, no end boundary (testSeconds rounds to 20.00) - expect(statsTesting.getTimerBoundaries(events)).toHaveLength(20); + expect(statsTesting.getTimerBoundaries(eventLog)).toHaveLength(20); }); it("skips end boundary in time mode even when endMs %1000 >= 500ms", () => { @@ -311,8 +319,8 @@ describe("stats.ts", () => { } logTestEvent("timer", 119994, timer("end", 120)); - const events = getAllTestEvents(); - const boundaries = statsTesting.getTimerBoundaries(events); + const eventLog = buildEventLog(); + const boundaries = statsTesting.getTimerBoundaries(eventLog); // 120 step boundaries, no end boundary expect(boundaries).toHaveLength(120); }); @@ -327,8 +335,8 @@ describe("stats.ts", () => { } logTestEvent("timer", 29994, timer("end", 30)); - const events = getAllTestEvents(); - expect(statsTesting.getTimerBoundaries(events)).toHaveLength(30); + const eventLog = buildEventLog(); + expect(statsTesting.getTimerBoundaries(eventLog)).toHaveLength(30); } finally { customTextLimit.mode = "words"; } @@ -357,8 +365,8 @@ describe("stats.ts", () => { } logTestEvent("timer", endMs, timer("end", fullSeconds)); - const events = getAllTestEvents(); - const boundaries = statsTesting.getTimerBoundaries(events); + const eventLog = buildEventLog(); + const boundaries = statsTesting.getTimerBoundaries(eventLog); const roundedDuration = Math.round(endMs / 1000); expect(boundaries).toHaveLength(roundedDuration); }); @@ -371,14 +379,14 @@ describe("stats.ts", () => { logTestEvent("timer", 1000, timer("start", 0)); logTestEvent("keydown", 1150, keyDown()); - expect(getStartToFirstKeypressMs()).toBe(150); + expect(getStartToFirstKeypressMs(buildEventLog())).toBe(150); }); it("returns 0 if keydown comes before start", () => { logTestEvent("keydown", 900, keyDown()); logTestEvent("timer", 1000, timer("start", 0)); - expect(getStartToFirstKeypressMs()).toBe(0); + expect(getStartToFirstKeypressMs(buildEventLog())).toBe(0); }); it("returns 0 in zen mode", () => { @@ -386,11 +394,11 @@ describe("stats.ts", () => { logTestEvent("timer", 1000, timer("start", 0)); logTestEvent("keydown", 1150, keyDown()); - expect(getStartToFirstKeypressMs()).toBe(0); + expect(getStartToFirstKeypressMs(buildEventLog())).toBe(0); }); it("returns 0 if no events", () => { - expect(getStartToFirstKeypressMs()).toBe(0); + expect(getStartToFirstKeypressMs(buildEventLog())).toBe(0); }); }); @@ -402,7 +410,7 @@ describe("stats.ts", () => { logTestEvent("keydown", 1800, keyDown()); logTestEvent("timer", 2000, timer("end", 1)); - expect(getLastKeypressToEndMs()).toBe(200); + expect(getLastKeypressToEndMs(buildEventLog())).toBe(200); }); it("returns 0 in zen mode", () => { @@ -411,7 +419,7 @@ describe("stats.ts", () => { logTestEvent("keydown", 1500, keyDown()); logTestEvent("timer", 2000, timer("end", 1)); - expect(getLastKeypressToEndMs()).toBe(0); + expect(getLastKeypressToEndMs(buildEventLog())).toBe(0); }); }); @@ -421,12 +429,12 @@ describe("stats.ts", () => { logTestEvent("keydown", 1500, keyDown()); logTestEvent("timer", 4000, timer("end", 3)); - expect(getTestDurationMs()).toBe(3000); + expect(getTestDurationMs(buildEventLog())).toBe(3000); }); it("returns 0 if no end event", () => { logTestEvent("timer", 1000, timer("start", 0)); - expect(getTestDurationMs()).toBe(0); + expect(getTestDurationMs(buildEventLog())).toBe(0); }); }); @@ -434,7 +442,7 @@ describe("stats.ts", () => { it("converts keypresses to WPM using real interval duration", () => { setupBasicTest(); - const raw = getBurstHistory(); + const raw = getBurstHistory(buildEventLog()); // 3 keypresses in 1s = (3/5)*60 = 36 WPM expect(raw[0]).toBe(36); // 2 keypresses in 1s = (2/5)*60 = 24 WPM @@ -454,7 +462,7 @@ describe("stats.ts", () => { logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2000, timer("end", 1)); - const raw = getBurstHistory(); + const raw = getBurstHistory(buildEventLog()); expect(raw).toEqual([12]); // 1 keypress in 1s }); }); @@ -470,13 +478,13 @@ describe("stats.ts", () => { logTestEvent("timer", 3000, timer("step", 2)); logTestEvent("timer", 3000, timer("end", 2)); - const errors = getErrorCountHistory(); + const errors = getErrorCountHistory(buildEventLog()); expect(errors).toEqual([1, 2]); }); it("returns zeros when all correct", () => { setupBasicTest(); - const errors = getErrorCountHistory(); + const errors = getErrorCountHistory(buildEventLog()); expect(errors).toEqual([0, 0, 0]); }); }); @@ -494,7 +502,7 @@ describe("stats.ts", () => { logTestEvent("timer", 4000, timer("step", 3)); logTestEvent("timer", 4000, timer("end", 3)); - expect(getAfkDuration()).toBe(1); + expect(getAfkDuration(buildEventLog())).toBe(1); }); it("returns 0 when all intervals have keydowns", () => { @@ -507,7 +515,7 @@ describe("stats.ts", () => { logTestEvent("timer", 3000, timer("step", 2)); logTestEvent("timer", 3000, timer("end", 2)); - expect(getAfkDuration()).toBe(0); + expect(getAfkDuration(buildEventLog())).toBe(0); }); }); @@ -609,7 +617,7 @@ describe("stats.ts", () => { logTestEvent("timer", 5000, timer("end", 4)); - const history = getInputHistory(); + const history = getInputHistory(buildEventLog()); expect(history[0]).toBe(""); expect(history[1]).toBe(""); }); @@ -621,14 +629,14 @@ describe("stats.ts", () => { logTestEvent("input", 1200, input({ charIndex: 1 })); logTestEvent("input", 1300, input({ charIndex: 2, correct: false })); - const acc = getAccuracy(); + const acc = getAccuracy(buildEventLog()); expect(acc.correct).toBe(2); expect(acc.incorrect).toBe(1); expect(acc.percentage).toBeCloseTo(66.67, 1); }); it("returns 0% for no events", () => { - const acc = getAccuracy(); + const acc = getAccuracy(buildEventLog()); expect(acc.percentage).toBe(0); }); @@ -640,7 +648,7 @@ describe("stats.ts", () => { inputType: "deleteContentBackward", } as InputEventData); - const acc = getAccuracy(); + const acc = getAccuracy(buildEventLog()); expect(acc.correct).toBe(1); expect(acc.incorrect).toBe(0); }); @@ -653,7 +661,7 @@ describe("stats.ts", () => { input({ charIndex: 1, correct: false, inputStopped: true }), ); - const acc = getAccuracy(); + const acc = getAccuracy(buildEventLog()); expect(acc.correct).toBe(1); expect(acc.incorrect).toBe(1); expect(acc.percentage).toBe(50); @@ -669,14 +677,14 @@ describe("stats.ts", () => { logTestEvent("keydown", 1250, keyDown()); logTestEvent("keyup", 1300, keyUp()); - const spacings = getKeypressSpacing(); + const spacings = getKeypressSpacing(buildEventLog()); expect(spacings).toEqual([100, 150]); }); it("returns empty for single keydown", () => { logTestEvent("keydown", 1000, keyDown()); - expect(getKeypressSpacing()).toEqual([]); + expect(getKeypressSpacing(buildEventLog())).toEqual([]); }); it("clamps a pre-start first keydown so the timing invariant holds", () => { @@ -696,11 +704,18 @@ describe("stats.ts", () => { logTestEvent("timer", 1000, timer("step", 1)); logTestEvent("timer", 1000, timer("end", 1)); - const sumSpacing = getKeypressSpacing().reduce((a, b) => a + b, 0); + const sumSpacing = getKeypressSpacing(buildEventLog()).reduce( + (a, b) => a + b, + 0, + ); const total = - getStartToFirstKeypressMs() + sumSpacing + getLastKeypressToEndMs(); + getStartToFirstKeypressMs(buildEventLog()) + + sumSpacing + + getLastKeypressToEndMs(buildEventLog()); - expect(Math.abs(getTestDurationMs() - total)).toBeLessThan(100); + expect(Math.abs(getTestDurationMs(buildEventLog()) - total)).toBeLessThan( + 100, + ); }); it("cleanupData drops post-end keydowns so the timing invariant holds", () => { @@ -725,11 +740,18 @@ describe("stats.ts", () => { cleanupData(); - const sumSpacing = getKeypressSpacing().reduce((a, b) => a + b, 0); + const sumSpacing = getKeypressSpacing(buildEventLog()).reduce( + (a, b) => a + b, + 0, + ); const total = - getStartToFirstKeypressMs() + sumSpacing + getLastKeypressToEndMs(); + getStartToFirstKeypressMs(buildEventLog()) + + sumSpacing + + getLastKeypressToEndMs(buildEventLog()); - expect(Math.abs(getTestDurationMs() - total)).toBeLessThan(100); + expect(Math.abs(getTestDurationMs(buildEventLog()) - total)).toBeLessThan( + 100, + ); }); }); @@ -741,7 +763,7 @@ describe("stats.ts", () => { logTestEvent("keyup", 1080, keyUp("KeyA")); logTestEvent("keyup", 1100, keyUp("KeyS")); - expect(getKeypressOverlap()).toBe(30); + expect(getKeypressOverlap(buildEventLog())).toBe(30); }); it("returns 0 with no overlap", () => { @@ -750,7 +772,7 @@ describe("stats.ts", () => { logTestEvent("keydown", 1100, keyDown("KeyS")); logTestEvent("keyup", 1150, keyUp("KeyS")); - expect(getKeypressOverlap()).toBe(0); + expect(getKeypressOverlap(buildEventLog())).toBe(0); }); }); @@ -761,14 +783,14 @@ describe("stats.ts", () => { logTestEvent("keydown", 1100, keyDown("KeyS")); logTestEvent("keyup", 1200, keyUp("KeyS")); - const durations = getKeypressDurations(); + const durations = getKeypressDurations(buildEventLog()); expect(durations).toEqual([80, 100]); }); it("returns 0 for keys without keyup", () => { logTestEvent("keydown", 1000, keyDown()); - const durations = getKeypressDurations(); + const durations = getKeypressDurations(buildEventLog()); expect(durations).toEqual([0]); }); }); @@ -777,7 +799,7 @@ describe("stats.ts", () => { it("counts insertText events per timer interval", () => { setupBasicTest(); - const kps = getKeypressesPerSecond(); + const kps = getKeypressesPerSecond(buildEventLog()); expect(kps).toEqual([3, 2, 1]); }); @@ -792,12 +814,12 @@ describe("stats.ts", () => { logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2000, timer("end", 1)); - expect(getKeypressesPerSecond()).toEqual([1]); + expect(getKeypressesPerSecond(buildEventLog())).toEqual([1]); }); it("returns empty for no timer events", () => { logTestEvent("input", 1200, input()); - expect(getKeypressesPerSecond()).toEqual([]); + expect(getKeypressesPerSecond(buildEventLog())).toEqual([]); }); it("counts keypresses in last partial second when gap rounds to 0.5s", () => { @@ -811,41 +833,53 @@ describe("stats.ts", () => { logTestEvent("timer", 2496, timer("end", 1)); // endMs=1496, 1496%1000=496ms → roundTo2(0.496)=0.5 → end boundary added → [1, 2] - expect(getKeypressesPerSecond()).toEqual([1, 2]); + expect(getKeypressesPerSecond(buildEventLog())).toEqual([1, 2]); }); }); describe("getTargetWord", () => { it("returns simulatedInput in zen mode", () => { (Config as { mode: string }).mode = "zen"; - expect(statsTesting.getTargetWord(0, "anything", false)).toBe("anything"); + expect( + statsTesting.getTargetWord(buildEventLog(), 0, "anything", false), + ).toBe("anything"); }); it("returns word without trailing space when it ends with newline", () => { TestWords.list.push("hello\n"); - expect(statsTesting.getTargetWord(0, "hello", false)).toBe("hello\n"); + expect( + statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), + ).toBe("hello\n"); }); it("appends trailing space for non-last word", () => { TestWords.list.push("hello"); - expect(statsTesting.getTargetWord(0, "hello", false)).toBe("hello "); + expect( + statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), + ).toBe("hello "); }); it("does not append trailing space for last word", () => { TestWords.list.push("hello"); - expect(statsTesting.getTargetWord(0, "hello", true)).toBe("hello"); + expect( + statsTesting.getTargetWord(buildEventLog(), 0, "hello", true), + ).toBe("hello"); }); it("does not append trailing space when nospace funbox is active", () => { TestWords.list.push("hello"); (Config as { funbox: string[] }).funbox = ["nospace"]; - expect(statsTesting.getTargetWord(0, "hello", false)).toBe("hello"); + expect( + statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), + ).toBe("hello"); }); it("does not append trailing space when underscore_spaces funbox is active", () => { TestWords.list.push("hello"); (Config as { funbox: string[] }).funbox = ["underscore_spaces"]; - expect(statsTesting.getTargetWord(0, "hello", false)).toBe("hello"); + expect( + statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), + ).toBe("hello"); }); }); @@ -863,7 +897,7 @@ describe("stats.ts", () => { ); } - const chars = getChars(); + const chars = getChars(buildEventLog()); expect(chars.allCorrect).toBe(5); expect(chars.correctWord).toBe(5); expect(chars.incorrect).toBe(0); @@ -887,7 +921,7 @@ describe("stats.ts", () => { input({ charIndex: 1, wordIndex: 0, data: "x", correct: false }), ); - const chars = getChars(); + const chars = getChars(buildEventLog()); expect(chars.allCorrect).toBe(1); expect(chars.incorrect).toBe(1); }); @@ -913,7 +947,7 @@ describe("stats.ts", () => { input({ charIndex: 2, wordIndex: 0, data: "c" }), ); - const chars = getChars(); + const chars = getChars(buildEventLog()); expect(chars.extra).toBe(1); }); @@ -955,7 +989,7 @@ describe("stats.ts", () => { input({ charIndex: 0, wordIndex: 1, data: "w" }), ); - const chars = getChars(); + const chars = getChars(buildEventLog()); // word 0: "hel " vs "hello " → 3 correct, 1 incorrect, 2 missed // word 1: "w" vs "world" → 1 correct, 4 missed (words mode counts partial last word missed) expect(chars.missed).toBe(6); @@ -979,7 +1013,7 @@ describe("stats.ts", () => { logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2000, timer("end", 1)); - const wpm = getWpmHistory(); + const wpm = getWpmHistory(buildEventLog()); // 5 correct chars in 1s = (5/5)*60 = 60 WPM expect(wpm).toEqual([60]); }); @@ -1020,7 +1054,7 @@ describe("stats.ts", () => { logTestEvent("timer", 3000, timer("step", 2)); logTestEvent("timer", 3000, timer("end", 2)); - const wpm = getWpmHistory(); + const wpm = getWpmHistory(buildEventLog()); expect(wpm.length).toBe(2); // at 1s: "ab " fully correct = 3 correctWord chars → (3/5)*60 = 36 expect(wpm[0]).toBe(36); @@ -1058,7 +1092,7 @@ describe("stats.ts", () => { logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2000, timer("end", 1)); - const wpm = getWpmHistory(); + const wpm = getWpmHistory(buildEventLog()); // both words fully correct → 4 correctWord chars in 1s = (4/5)*60 = 48 expect(wpm).toEqual([48]); }); @@ -1089,11 +1123,155 @@ describe("stats.ts", () => { logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2000, timer("end", 1)); - const wpm = getWpmHistory(); + const wpm = getWpmHistory(buildEventLog()); // word 0: "hello\n" target matches input "hello\n" → 6 correctWord // word 1: "world" (last word) matches → 5 correctWord // 11 chars in 1s = (11/5)*60 = 132 expect(wpm).toEqual([132]); }); }); + + describe("inferActiveWordIndex", () => { + it("returns 0 when no word has input", () => { + const eventsPerWord = new Map(); + expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(0); + }); + + it("returns 0 when entries exist but none have input", () => { + // word events present but all input data is empty / inputValue="" + logTestEvent( + "input", + 1000, + input({ wordIndex: 0, data: "", inputValue: "" }), + ); + const eventsPerWord = getEventsPerWord(getAllTestEvents()); + expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(0); + }); + + it("returns max wordIndex when last word has no committed space", () => { + // word 0: "hi" + logTestEvent("input", 1000, input({ wordIndex: 0, data: "h" })); + logTestEvent( + "input", + 1050, + input({ wordIndex: 0, charIndex: 1, data: "i" }), + ); + // space commit on word 0 + logTestEvent( + "input", + 1100, + input({ + wordIndex: 0, + charIndex: 2, + data: " ", + commitsWord: true, + inputValue: "hi ", + }), + ); + // word 1: "yo" (no trailing space) + logTestEvent("input", 1200, input({ wordIndex: 1, data: "y" })); + logTestEvent( + "input", + 1250, + input({ wordIndex: 1, charIndex: 1, data: "o" }), + ); + + const eventsPerWord = getEventsPerWord(getAllTestEvents()); + expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(1); + }); + + it("advances past last word when trailing space was committed", () => { + // word 0: "hi " + logTestEvent("input", 1000, input({ wordIndex: 0, data: "h" })); + logTestEvent( + "input", + 1050, + input({ wordIndex: 0, charIndex: 1, data: "i" }), + ); + logTestEvent( + "input", + 1100, + input({ + wordIndex: 0, + charIndex: 2, + data: " ", + commitsWord: true, + inputValue: "hi ", + }), + ); + + const eventsPerWord = getEventsPerWord(getAllTestEvents()); + expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(1); + }); + + it("does not advance when last event is a non-space insert", () => { + logTestEvent("input", 1000, input({ wordIndex: 0, data: "h" })); + logTestEvent( + "input", + 1050, + input({ wordIndex: 0, charIndex: 1, data: "i" }), + ); + + const eventsPerWord = getEventsPerWord(getAllTestEvents()); + expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(0); + }); + + it("does not advance when last event is a backspace", () => { + logTestEvent("input", 1000, input({ wordIndex: 0, data: "h" })); + logTestEvent( + "input", + 1050, + input({ wordIndex: 0, charIndex: 1, data: "i" }), + ); + logTestEvent( + "input", + 1100, + input({ + wordIndex: 0, + charIndex: 1, + inputType: "deleteContentBackward", + data: "", + inputValue: "h", + }), + ); + + const eventsPerWord = getEventsPerWord(getAllTestEvents()); + expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(0); + }); + + it("picks max wordIndex across non-contiguous buckets (post-regression order)", () => { + // simulates a backspace that crosses back into word 0 AFTER word 1 events. + // Map insertion order is still 0, 1 (word 0 was set first), so the loop + // must compute true max by key, not by iteration position. + logTestEvent("input", 1000, input({ wordIndex: 0, data: "h" })); + logTestEvent( + "input", + 1050, + input({ + wordIndex: 0, + charIndex: 1, + data: " ", + commitsWord: true, + inputValue: "h ", + }), + ); + logTestEvent("input", 1100, input({ wordIndex: 1, data: "y" })); + // backspace lands a destination event back into word 0 + logTestEvent( + "input", + 1200, + input({ + wordIndex: 0, + charIndex: 1, + inputType: "deleteContentBackward", + data: "", + inputValue: "h", + }), + ); + + const eventsPerWord = getEventsPerWord(getAllTestEvents()); + // word 1 has input "y" (no trailing space) → max is 1, no advance + expect(statsTesting.inferActiveWordIndex(eventsPerWord)).toBe(1); + }); + }); }); diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index f579bb2a44a2..14a455c6441e 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -40,8 +40,7 @@ import { showFpsCounter, } from "../components/layout/overlays/FpsCounter"; import { applyConfigFromJson } from "../config/lifecycle"; -import { getAllTestEvents } from "../test/events/data"; -import * as TestWords from "../test/test-words"; +import { buildEventLog } from "../test/events/data"; const challengesPromise = JSONData.getChallengeList(); challengesPromise @@ -308,18 +307,13 @@ export const commands: CommandsSubgroup = { }, { id: "copyResultData", - display: "Copy result data", + display: "Copy event log (result data)", alias: "stats events", icon: "fa-cog", visible: false, exec: async (): Promise => { navigator.clipboard - .writeText( - JSON.stringify({ - events: getAllTestEvents(), - words: TestWords.words.list, - }), - ) + .writeText(JSON.stringify(buildEventLog())) .then(() => { showSuccessNotification("Copied to clipboard"); }) diff --git a/frontend/src/ts/components/dev/DevTools.tsx b/frontend/src/ts/components/dev/DevTools.tsx index 8e72594a021f..b3414013bda7 100644 --- a/frontend/src/ts/components/dev/DevTools.tsx +++ b/frontend/src/ts/components/dev/DevTools.tsx @@ -13,9 +13,9 @@ if (import.meta.env.DEV) { default: m.DevOptionsModal, })), ); - const LazyTestDataPreviewModal = lazy(async () => - import("../modals/TestDataPreviewModal").then((m) => ({ - default: m.TestDataPreviewModal, + const LazyEventLogViewerModal = lazy(async () => + import("../modals/EventLogViewerModal").then((m) => ({ + default: m.EventLogViewerModal, })), ); @@ -38,7 +38,7 @@ if (import.meta.env.DEV) { - + ); diff --git a/frontend/src/ts/components/modals/DevOptionsModal.tsx b/frontend/src/ts/components/modals/DevOptionsModal.tsx index 9548ea09a7fc..4f78c89a6367 100644 --- a/frontend/src/ts/components/modals/DevOptionsModal.tsx +++ b/frontend/src/ts/components/modals/DevOptionsModal.tsx @@ -168,8 +168,8 @@ export function DevOptionsModal(): JSXElement { }, { icon: "fa-vials", - label: () => "Test Data Preview", - onClick: () => showModal("TestDataPreview"), + label: () => "Event Log Viewer", + onClick: () => showModal("EventLogViewer"), }, ]; diff --git a/frontend/src/ts/components/modals/TestDataPreviewModal.tsx b/frontend/src/ts/components/modals/EventLogViewerModal.tsx similarity index 96% rename from frontend/src/ts/components/modals/TestDataPreviewModal.tsx rename to frontend/src/ts/components/modals/EventLogViewerModal.tsx index 9046446e489a..a9946ec8fe83 100644 --- a/frontend/src/ts/components/modals/TestDataPreviewModal.tsx +++ b/frontend/src/ts/components/modals/EventLogViewerModal.tsx @@ -10,22 +10,19 @@ import { } from "solid-js"; import type { + EventLog, InputEventNoMs, - TestEvent, + TestEventNoMs, TestEventType, } from "../../test/events/types"; import { hideModal } from "../../states/modals"; import { getInputFromDom } from "../../test/events/helpers"; +import { EVENT_LOG_VERSION } from "../../test/events/types"; import { cn } from "../../utils/cn"; import { AnimatedModal } from "../common/AnimatedModal"; import { Button } from "../common/Button"; -type TestContext = { - events: TestEvent[]; - words: string[]; -}; - type Stage = "input" | "preview"; const EVENT_TYPES: TestEventType[] = [ @@ -62,7 +59,7 @@ type TimelineSegment = { type RawSegment = Omit; function buildLanes( - events: TestEvent[], + events: TestEventNoMs[], visible: Set, ): { segments: TimelineSegment[]; totalHeight: number } { const byType = new Map(); @@ -183,34 +180,44 @@ function buildLanes( return { segments, totalHeight: Math.max(y, TIMELINE_TRACK_HEIGHT) }; } -function parseContext(raw: string): TestContext { +function parseContext(raw: string): EventLog { const parsed = JSON.parse(raw) as unknown; - if (typeof parsed !== "object") { - throw new Error("Expected an object"); - } - if (parsed === null) { - throw new Error("Expected an object, got null"); + if (typeof parsed !== "object" || parsed === null) { + throw new Error("Expected an EventLog object"); } - if (!("events" in parsed) || !("words" in parsed)) { - throw new Error("Expected { events: TestEvent[], words: string[] }"); + if ( + !("version" in parsed) || + !("events" in parsed) || + !("context" in parsed) + ) { + throw new Error( + `Expected EventLog { version: ${EVENT_LOG_VERSION}, events, context }`, + ); } - if (typeof parsed.words === "string") { - parsed.words = parsed.words.split(" "); + if (parsed.version !== EVENT_LOG_VERSION) { + throw new Error( + `Unsupported EventLog version ${String(parsed.version)} (expected ${EVENT_LOG_VERSION})`, + ); } - if (!Array.isArray((parsed as TestContext).events)) { - throw new Error("Expected { events: TestEvent[], words: string[] }"); + if (!Array.isArray((parsed as EventLog).events)) { + throw new Error("EventLog.events must be an array"); } - if (!Array.isArray((parsed as TestContext).words)) { - throw new Error("Expected { events: TestEvent[], words: string[] }"); + const ctx = (parsed as EventLog).context as unknown; + if ( + typeof ctx !== "object" || + ctx === null || + !Array.isArray((ctx as { targetWords?: unknown }).targetWords) + ) { + throw new Error("EventLog.context.targetWords must be a string array"); } - return parsed as TestContext; + return parsed as EventLog; } function visualizeWhitespace(s: string): string { return s.replace(/ /g, "␣").replace(/\t/g, "→").replace(/\n/g, "↵"); } -function inputsPerWord(events: TestEvent[], wordCount: number): string[] { +function inputsPerWord(events: TestEventNoMs[], wordCount: number): string[] { const buckets = new Map(); for (const e of events) { if (e.type !== "input") continue; @@ -223,10 +230,10 @@ function inputsPerWord(events: TestEvent[], wordCount: number): string[] { ); } -export function TestDataPreviewModal(): JSXElement { +export function EventLogViewerModal(): JSXElement { const [stage, setStage] = createSignal("input"); const [raw, setRaw] = createSignal(""); - const [ctx, setCtx] = createSignal(null); + const [ctx, setCtx] = createSignal(null); const [err, setErr] = createSignal(null); const reset = (): void => { @@ -249,8 +256,8 @@ export function TestDataPreviewModal(): JSXElement { return ( @@ -258,7 +265,7 @@ export function TestDataPreviewModal(): JSXElement {