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
8 changes: 4 additions & 4 deletions frontend/__tests__/test/events/stats.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
import {
getStartToFirstKeypressMs,
getLastKeypressToEndMs,
getRawPerSecond,
getBurstHistory,
getTestDurationMs,
getAccuracy,
getKeypressSpacing,
Expand Down Expand Up @@ -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
Expand All @@ -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
});
});
Expand Down
301 changes: 251 additions & 50 deletions frontend/src/ts/test/events/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand Down Expand Up @@ -268,47 +268,23 @@ function getTargetWord(
}
}

function countCharsForWords(
eventsPerWord: Map<number, TestEventNoMs[]>,
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(
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -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<number, TestEventNoMs[]>();
const cachedIfLast = new Map<number, number>();
const cachedIfNotLast = new Map<number, number>();
const dirty = new Set<number>();
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<number, TestEventNoMs[]>();
const cachedIfLast = new Map<number, number>();
const cachedIfNotLast = new Map<number, number>();
const dirty = new Set<number>();
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<number, TestEventNoMs[]>();
// const cachedIfLast = new Map<number, number>();
// const cachedIfNotLast = new Map<number, number>();
// const dirty = new Set<number>();
// 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",
Expand Down
Loading
Loading