diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index db225524e2b2..24e68977d08d 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -47,7 +47,7 @@ import { import { getStartToFirstKeypressMs, getLastKeypressToEndMs, - getRawPerSecond, + getBurstHistory, getTestDurationMs, getAccuracy, getKeypressSpacing, @@ -431,11 +431,11 @@ describe("stats.ts", () => { }); }); - describe("getRawPerSecond", () => { + describe("getBurstHistory", () => { it("converts keypresses to WPM using real interval duration", () => { setupBasicTest(); - const raw = getRawPerSecond(); + const raw = getBurstHistory(); // 3 keypresses in 1s = (3/5)*60 = 36 WPM expect(raw[0]).toBe(36); // 2 keypresses in 1s = (2/5)*60 = 24 WPM @@ -455,7 +455,7 @@ describe("stats.ts", () => { logTestEvent("timer", 2000, timer("step", 1)); logTestEvent("timer", 2000, timer("end", 1)); - const raw = getRawPerSecond(); + const raw = getBurstHistory(); expect(raw).toEqual([12]); // 1 keypress in 1s }); }); diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 144737965cec..6fdd70e0cfb9 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -180,7 +180,7 @@ export function getKeypressesPerSecond(): number[] { return counts; } -export function getRawPerSecond(): number[] { +export function getBurstHistory(): number[] { const { counts, boundaries } = countPerInterval( (e) => e.type === "input" && e.data.inputType === "insertText", ); @@ -268,47 +268,23 @@ function getTargetWord( } } -function countCharsForWords( - eventsPerWord: Map, - lastWordIndex: number, - shouldCountPartialLastWord: boolean, +function countCharsForWordIndex( + wordIndex: number, + events: TestEventNoMs[], + lastWord: boolean, + countPartial: boolean, ): CharCounts { - const acc: CharCounts = { - allCorrect: 0, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 0, - }; - - for (const [wordIndex, events] of eventsPerWord) { - const lastWord = wordIndex === lastWordIndex; - - let simulatedInput = getInputFromDom(events); - if (koreanStatus) { - simulatedInput = Hangul.disassemble(simulatedInput).join(""); - } - - let targetWord = getTargetWord(wordIndex, simulatedInput, lastWord); - if (koreanStatus) { - targetWord = Hangul.disassemble(targetWord).join(""); - } - - 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; + let simulatedInput = getInputFromDom(events); + if (koreanStatus) { + simulatedInput = Hangul.disassemble(simulatedInput).join(""); + } - if (lastWord) break; + let targetWord = getTargetWord(wordIndex, simulatedInput, lastWord); + if (koreanStatus) { + targetWord = Hangul.disassemble(targetWord).join(""); } - return acc; + return countChars(simulatedInput, targetWord, lastWord && countPartial); } function inferActiveWordIndex( @@ -341,11 +317,31 @@ export function getChars(): CharCounts { Config.mode === "time" || (Config.mode === "words" && Config.words === 0) || (Config.mode === "custom" && CustomText.getLimit().mode === "time"); - return countCharsForWords( - getEventsPerWord(), - isTimedTest ? activeWordIndex : TestWords.words.list.length - 1, - isTimedTest, - ); + const lastWordIndex = isTimedTest + ? activeWordIndex + : TestWords.words.list.length - 1; + + const acc: CharCounts = { + allCorrect: 0, + correctWord: 0, + incorrect: 0, + extra: 0, + missed: 0, + }; + + for (const [wordIndex, events] of getEventsPerWord()) { + const lastWord = wordIndex === lastWordIndex; + const c = countCharsForWordIndex(wordIndex, events, lastWord, isTimedTest); + acc.allCorrect += c.allCorrect; + acc.correctWord += c.correctWord; + acc.incorrect += c.incorrect; + acc.extra += c.extra; + acc.missed += c.missed; + + if (lastWord) break; + } + + return acc; } export function getInputHistory(): string[] { @@ -475,22 +471,227 @@ export function getErrorCountHistory(): number[] { export function getWpmHistory(): number[] { const events = getAllTestEvents(); + const boundaries = getTimerBoundaries(events); + if (boundaries.length === 0) return []; + + const eventsPerWord = new Map(); + const cachedIfLast = new Map(); + const cachedIfNotLast = new Map(); + const dirty = new Set(); const wpmHistory: number[] = []; - for (const boundary of getTimerBoundaries(events)) { - const eventsPerWord = getEventsPerWord(undefined, boundary); + let eventIdx = 0; + + for (const boundary of boundaries) { + // incrementally extend eventsPerWord with events up to this boundary + while (eventIdx < events.length) { + const event = events[eventIdx]; + if (event === undefined || event.testMs > boundary) break; + + if ("wordIndex" in event.data) { + const wordIndex = event.data.wordIndex; + let list = eventsPerWord.get(wordIndex); + if (list === undefined) { + list = []; + eventsPerWord.set(wordIndex, list); + } + list.push(event); + dirty.add(wordIndex); + } + eventIdx++; + } + + // recompute correctWord (for both last/not-last roles) only for words + // whose event lists changed since the previous boundary + for (const wordIndex of dirty) { + const wordEvents = eventsPerWord.get(wordIndex); + if (wordEvents === undefined) continue; + cachedIfNotLast.set( + wordIndex, + countCharsForWordIndex(wordIndex, wordEvents, false, true).correctWord, + ); + cachedIfLast.set( + wordIndex, + countCharsForWordIndex(wordIndex, wordEvents, true, true).correctWord, + ); + } + dirty.clear(); + const lastWordIndex = inferActiveWordIndex(eventsPerWord); - const { correctWord } = countCharsForWords( - eventsPerWord, - lastWordIndex, - true, - ); + + let correctWord = 0; + for (const wordIndex of eventsPerWord.keys()) { + if (wordIndex === lastWordIndex) { + correctWord += cachedIfLast.get(wordIndex) ?? 0; + break; + } + correctWord += cachedIfNotLast.get(wordIndex) ?? 0; + } + wpmHistory.push(Math.round(calculateWpm(correctWord, boundary / 1000))); } return wpmHistory; } +export function getRawHistory(): number[] { + const events = getAllTestEvents(); + const boundaries = getTimerBoundaries(events); + if (boundaries.length === 0) return []; + + const eventsPerWord = new Map(); + const cachedIfLast = new Map(); + const cachedIfNotLast = new Map(); + const dirty = new Set(); + const rawHistory: number[] = []; + + let eventIdx = 0; + + for (const boundary of boundaries) { + // incrementally extend eventsPerWord with events up to this boundary + while (eventIdx < events.length) { + const event = events[eventIdx]; + if (event === undefined || event.testMs > boundary) break; + + if ("wordIndex" in event.data) { + const wordIndex = event.data.wordIndex; + let list = eventsPerWord.get(wordIndex); + if (list === undefined) { + list = []; + eventsPerWord.set(wordIndex, list); + } + list.push(event); + dirty.add(wordIndex); + } + eventIdx++; + } + + // recompute correctWord (for both last/not-last roles) only for words + // whose event lists changed since the previous boundary + for (const wordIndex of dirty) { + const wordEvents = eventsPerWord.get(wordIndex); + if (wordEvents === undefined) continue; + + const notLastCount = countCharsForWordIndex( + wordIndex, + wordEvents, + false, + true, + ); + const lastCount = countCharsForWordIndex( + wordIndex, + wordEvents, + true, + true, + ); + + cachedIfNotLast.set( + wordIndex, + notLastCount.allCorrect + notLastCount.extra + notLastCount.incorrect, + ); + cachedIfLast.set( + wordIndex, + lastCount.allCorrect + lastCount.extra + lastCount.incorrect, + ); + } + dirty.clear(); + + const lastWordIndex = inferActiveWordIndex(eventsPerWord); + + let chars = 0; + for (const wordIndex of eventsPerWord.keys()) { + if (wordIndex === lastWordIndex) { + chars += cachedIfLast.get(wordIndex) ?? 0; + break; + } + chars += cachedIfNotLast.get(wordIndex) ?? 0; + } + + rawHistory.push(Math.round(calculateWpm(chars, boundary / 1000))); + } + + return rawHistory; +} + +// export function getRawHistory(): number[] { +// const events = getAllTestEvents(); +// const boundaries = getTimerBoundaries(events); +// if (boundaries.length === 0) return []; + +// const eventsPerWord = new Map(); +// const cachedIfLast = new Map(); +// const cachedIfNotLast = new Map(); +// const dirty = new Set(); +// const wpmHistory: number[] = []; + +// let eventIdx = 0; + +// for (const boundary of boundaries) { +// while (eventIdx < events.length) { +// const event = events[eventIdx]; +// if (event === undefined || event.testMs > boundary) break; + +// if ("wordIndex" in event.data) { +// const wordIndex = event.data.wordIndex; +// let list = eventsPerWord.get(wordIndex); +// if (list === undefined) { +// list = []; +// eventsPerWord.set(wordIndex, list); +// } +// list.push(event); +// dirty.add(wordIndex); +// } +// eventIdx++; +// } + +// for (const wordIndex of dirty) { +// const wordEvents = eventsPerWord.get(wordIndex); +// if (wordEvents === undefined) continue; + +// const input = getInputFromDom(wordEvents); +// if (input.length === 0) { +// cachedIfNotLast.set(wordIndex, 0); +// cachedIfLast.set(wordIndex, 0); +// continue; +// } + +// const wordText = +// Config.mode === "zen" ? "" : (TestWords.words.getText(wordIndex) ?? ""); + +// const notLast = countChars(input, `${wordText} `, true); +// cachedIfNotLast.set( +// wordIndex, +// notLast.allCorrect + notLast.extra + notLast.incorrect, +// ); + +// const trimmed = input.trimEnd(); +// const last = countChars( +// trimmed, +// Config.mode === "zen" ? trimmed : wordText, +// true, +// ); +// cachedIfLast.set( +// wordIndex, +// last.allCorrect + last.extra + last.incorrect, +// ); +// } +// dirty.clear(); + +// const lastWordIndex = inferActiveWordIndex(eventsPerWord); + +// let totalCorrect = 0; +// for (const wordIndex of eventsPerWord.keys()) { +// const cache = +// wordIndex === lastWordIndex ? cachedIfLast : cachedIfNotLast; +// totalCorrect += cache.get(wordIndex) ?? 0; +// } + +// wpmHistory.push(Math.round(calculateWpm(totalCorrect, boundary / 1000))); +// } + +// return wpmHistory; +// } + export function getAfkDuration(): number { const { counts } = countPerInterval( (e) => e.type === "keydown" || e.type === "input", diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 04f0ee3c40cb..88711c5aff3d 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -107,7 +107,8 @@ import { import { getKeypressDurations, getChars, - getRawPerSecond, + getBurstHistory, + getRawHistory, getLastKeypressToEndMs, getStartToFirstKeypressMs, getTestDurationMs, @@ -1251,9 +1252,36 @@ function compareCompletedEvents( } } + { + const a = TestInput.rawHistory; + const b = getRawHistory(); + if (a.length === b.length && a.every((val, i) => val === b[i])) { + console.debug(`Completed event match on rawHistory:`, a); + } else { + notMatching.push(`rawHistory (values differ)`); + mismatchedKeys.push("rawHistory"); + console.error(`Completed event mismatch on rawHistory:`, a, b); + } + } + + { + if (ce.chartData !== "toolong") { + const a = ce.chartData.wpm; + const b = getWpmHistory(); + if (a.length === b.length && a.every((val, i) => val === b[i])) { + console.debug(`Completed event match on chartData.wpm:`, a); + } else { + notMatching.push(`chartData.wpm (values differ)`); + mismatchedKeys.push("chartData.wpm"); + console.error(`Completed event mismatch on chartData.wpm:`, a, b); + } + } + } + { const a = getInputHistory().join(" "); - if (!a.includes("\n")) { + const noSpace = isFunboxActiveWithProperty("nospace"); + if (!a.includes("\n") && !noSpace) { const b = getEventsInputHistory().join(""); if (a === b) { console.debug(`Completed event match on input history:`, a); @@ -1385,7 +1413,7 @@ function buildCompletedEvent2(): Omit { let duration = getTestDurationMs() / 1000; - const rawPerSecond = getRawPerSecond(); + const rawPerSecond = getBurstHistory(); const afkDuration = getAfkDuration(); const stddev = Numbers.stdDev(rawPerSecond); const avg = Numbers.mean(rawPerSecond);