From 363f445d6f3f4ad447fb0ba4d26ea2a7e0b8be70 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 22 Jun 2026 09:30:15 +0200 Subject: [PATCH 01/40] ai --- .../input/helpers/validation.spec.ts | 4 +- frontend/__tests__/test/events/stats.spec.ts | 41 ++++++++-------- frontend/__tests__/test/test-words.spec.ts | 48 +++++++++++++++++++ .../src/ts/commandline/lists/result-screen.ts | 6 ++- frontend/src/ts/elements/caret.ts | 7 ++- .../src/ts/input/handlers/before-delete.ts | 5 +- .../ts/input/handlers/before-insert-text.ts | 5 +- frontend/src/ts/input/handlers/insert-text.ts | 4 +- frontend/src/ts/input/helpers/validation.ts | 11 +++-- frontend/src/ts/input/listeners/input.ts | 4 +- frontend/src/ts/test/events/stats.ts | 28 ++++------- .../src/ts/test/funbox/funbox-functions.ts | 4 +- frontend/src/ts/test/pace-caret.ts | 15 ++++-- frontend/src/ts/test/practise-words.ts | 7 ++- frontend/src/ts/test/replay-ui.ts | 7 ++- frontend/src/ts/test/test-logic.ts | 20 ++++++-- frontend/src/ts/test/test-ui.ts | 14 ++++-- frontend/src/ts/test/test-words.ts | 16 +++++++ frontend/src/ts/utils/strings.ts | 13 +++++ 19 files changed, 191 insertions(+), 68 deletions(-) create mode 100644 frontend/__tests__/test/test-words.spec.ts diff --git a/frontend/__tests__/input/helpers/validation.spec.ts b/frontend/__tests__/input/helpers/validation.spec.ts index 681394cb863e..37f3ec01f6d9 100644 --- a/frontend/__tests__/input/helpers/validation.spec.ts +++ b/frontend/__tests__/input/helpers/validation.spec.ts @@ -100,12 +100,12 @@ describe("isCharCorrect", () => { describe("Space Handling at the end of a word", () => { it.each([ - ["returns true at the end of a correct word", " ", "word", "word", true], + ["returns true at the end of a correct word", " ", "word", "word ", true], [ "returns false at the end of an incorrect word", " ", "worx", - "word", + "word ", false, ], [ diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 15746a67b528..1d124c69bd1f 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -1013,42 +1013,41 @@ describe("stats.ts", () => { ).toBe("anything"); }); - it("returns word without trailing space when it ends with newline", () => { - TestWords.list.push("hello\n"); - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), - ).toBe("hello\n"); - }); - - it("appends trailing space for non-last word", () => { - TestWords.list.push("hello"); + it("returns the stored word as-is for a non-last word", () => { + // storage keeps the separator as a trailing space + TestWords.list.push("hello "); expect( statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), ).toBe("hello "); }); - it("does not append trailing space for last word", () => { - TestWords.list.push("hello"); + it("strips the trailing separator for the last word", () => { + // the last reached word has no committed separator (the test ended) + TestWords.list.push("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"]; + it("returns a newline-terminated word as-is", () => { + TestWords.list.push("hello\n"); expect( statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), - ).toBe("hello"); + ).toBe("hello\n"); }); - it("does not append trailing space when underscore_spaces funbox is active", () => { + it("returns a bare word as-is (nospace storage)", () => { TestWords.list.push("hello"); - (Config as { funbox: string[] }).funbox = ["underscore_spaces"]; expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), + statsTesting.getTargetWord(buildEventLog(), 0, "hello", true), ).toBe("hello"); }); + + it("returns empty string for an out-of-range word index", () => { + expect( + statsTesting.getTargetWord(buildEventLog(), 5, "hello", false), + ).toBe(""); + }); }); describe("getChars", () => { @@ -1120,7 +1119,8 @@ describe("stats.ts", () => { }); it("counts missed chars for completed non-last words", () => { - TestWords.list.push("hello", "world"); + // stored words carry the separator as a trailing space (last word is bare) + TestWords.list.push("hello ", "world"); (TestState as { activeWordIndex: number }).activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); @@ -1187,7 +1187,8 @@ describe("stats.ts", () => { }); it("returns cumulative wpm across boundaries", () => { - TestWords.list.push("ab", "cd"); + // stored words carry the separator as a trailing space (last word is bare) + TestWords.list.push("ab ", "cd"); (TestState as { activeWordIndex: number }).activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); diff --git a/frontend/__tests__/test/test-words.spec.ts b/frontend/__tests__/test/test-words.spec.ts new file mode 100644 index 000000000000..84defea31390 --- /dev/null +++ b/frontend/__tests__/test/test-words.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../../src/ts/test/test-state", () => ({ + activeWordIndex: 0, +})); + +import { words } from "../../src/ts/test/test-words"; + +describe("test-words", () => { + beforeEach(() => { + words.reset(); + words.setNospace(false); + }); + + describe("push (separator storage)", () => { + it("stores the separator as a trailing space on each non-last word", () => { + words.push("the", 0); + words.push("cat", 0); + words.push("sat", 0); + expect(words.list).toEqual(["the ", "cat ", "sat"]); + }); + + it("leaves a single word bare", () => { + words.push("hello", 0); + expect(words.list).toEqual(["hello"]); + }); + + it("terminates the previous word as new words are appended mid-test", () => { + words.push("a", 0); + expect(words.list).toEqual(["a"]); + words.push("b", 0); + expect(words.list).toEqual(["a ", "b"]); + }); + + it("does not add a space after a newline-terminated word", () => { + words.push("line\n", 0); + words.push("next", 0); + expect(words.list).toEqual(["line\n", "next"]); + }); + + it("adds no separators when nospace is set", () => { + words.setNospace(true); + words.push("猫", 0); + words.push("犬", 0); + expect(words.list).toEqual(["猫", "犬"]); + }); + }); +}); diff --git a/frontend/src/ts/commandline/lists/result-screen.ts b/frontend/src/ts/commandline/lists/result-screen.ts index df7fe2a203ca..753376e43a77 100644 --- a/frontend/src/ts/commandline/lists/result-screen.ts +++ b/frontend/src/ts/commandline/lists/result-screen.ts @@ -7,6 +7,7 @@ import { } from "../../states/notifications"; import * as TestState from "../../test/test-state"; import * as TestWords from "../../test/test-words"; +import { removeTrailingSeparator } from "../../utils/strings"; import { Config } from "../../config/store"; import * as PractiseWords from "../../test/practise-words"; import { Command, CommandsSubgroup } from "../types"; @@ -148,7 +149,10 @@ const commands: Command[] = [ const words = Config.mode === "zen" ? inputHistory.join("") - : TestWords.words.list.slice(0, inputHistory.length).join(" "); + : TestWords.words.list + .slice(0, inputHistory.length) + .map(removeTrailingSeparator) + .join(" "); navigator.clipboard.writeText(words).then( () => { diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index 15c326971653..965f1fb6c4fc 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -2,7 +2,7 @@ import { CaretStyle } from "@monkeytype/schemas/configs"; import { Config } from "../config/store"; import * as TestWords from "../test/test-words"; import { getTotalInlineMargin } from "../utils/misc"; -import { isWordRightToLeft } from "../utils/strings"; +import { isWordRightToLeft, removeTrailingSeparator } from "../utils/strings"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { EasingParam, JSAnimation } from "animejs"; import { ElementWithUtils, qsr } from "../utils/dom"; @@ -287,7 +287,10 @@ export class Caret { const word = wordsCache.qs( `.word[data-wordindex="${options.wordIndex}"]`, ); - const wordText = TestWords.words.getText(options.wordIndex) ?? ""; + // strip the stored trailing separator space; it isn't rendered as a letter + const wordText = removeTrailingSeparator( + TestWords.words.getText(options.wordIndex) ?? "", + ); const wordLength = Array.from(wordText).length; // caret can be either on the left side of the target letter or the right diff --git a/frontend/src/ts/input/handlers/before-delete.ts b/frontend/src/ts/input/handlers/before-delete.ts index b8462972ee0a..cbb852a28e2f 100644 --- a/frontend/src/ts/input/handlers/before-delete.ts +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -5,6 +5,7 @@ import { getInputElementValue } from "../input-element"; import * as TestUI from "../../test/test-ui"; import { isAwaitingNextWord } from "../state"; import { getInputForWord } from "../../test/events/data"; +import { removeTrailingSeparator } from "../../utils/strings"; export function onBeforeDelete(event: InputEvent): void { if (!TestState.isActive) { @@ -52,7 +53,9 @@ export function onBeforeDelete(event: InputEvent): void { const confidence = Config.confidenceMode; const previousWordCorrect = getInputForWord(TestState.activeWordIndex - 1) === - TestWords.words.getText(TestState.activeWordIndex - 1); + removeTrailingSeparator( + TestWords.words.getText(TestState.activeWordIndex - 1) ?? "", + ); if (confidence === "on" && inputIsEmpty && !previousWordCorrect) { event.preventDefault(); diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 3c5f56419e6e..54da3a2e5063 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -4,7 +4,7 @@ import * as TestState from "../../test/test-state"; import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; import { isFunboxActiveWithProperty } from "../../test/funbox/list"; -import { isSpace } from "../../utils/strings"; +import { isSpace, removeTrailingSeparator } from "../../utils/strings"; import { getInputElementValue } from "../input-element"; import { isAwaitingNextWord } from "../state"; import { shouldInsertSpaceCharacter } from "../helpers/validation"; @@ -71,7 +71,8 @@ export function onBeforeInsertText(data: string): boolean { // this will not work for the first word of each line, but that has a low chance of happening const dataIsNotFalsy = data !== null && data !== ""; const inputIsLongerThanOrEqualToWord = - getCurrentInput().length >= TestWords.words.getCurrentText().length; + getCurrentInput().length >= + removeTrailingSeparator(TestWords.words.getCurrentText()).length; if ( !SlowTimer.get() && // don't do this check if slow timer is active diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 70ae26173981..59e94a17040c 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -152,8 +152,8 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const goingToNextWord = ((charIsSpace || charIsNewline) && !shouldInsertSpace) || noSpaceForce; - // when moving to the next word, correctness is word-level (a correct word-completing - // space has charCorrect === false, so charCorrect can't be used below) + // when moving to the next word, correctness is word-level (whether the whole + // word — including its trailing separator — was typed correctly) const correct = goingToNextWord ? (funboxCorrect ?? isWordCorrect({ diff --git a/frontend/src/ts/input/helpers/validation.ts b/frontend/src/ts/input/helpers/validation.ts index a2712335b3b2..89d11afbeeb1 100644 --- a/frontend/src/ts/input/helpers/validation.ts +++ b/frontend/src/ts/input/helpers/validation.ts @@ -1,5 +1,5 @@ import { Config } from "../../config/store"; -import { isSpace } from "../../utils/strings"; +import { isSpace, removeTrailingSeparator } from "../../utils/strings"; /** * Check if the input data is correct @@ -47,8 +47,9 @@ export function isWordCorrect(options: { if (Config.mode === "zen") return true; if (correctShiftUsed === false) return false; - const finalInputValue = inputValue + (isSpace(data) ? "" : data); - return finalInputValue === targetWord; + // The committing separator (space/newline) is part of the target word, so the + // typed control char completes it directly. + return inputValue + data === targetWord; } /** @@ -72,7 +73,9 @@ export function shouldInsertSpaceCharacter(options: { if (Config.mode === "zen") { return false; } - const correctSoFar = `${targetWord} `.startsWith(`${inputValue} `); + // correct so far means the full visible word has been typed correctly (the + // separator is the trailing space stored on the target word) + const correctSoFar = inputValue === removeTrailingSeparator(targetWord); const stopOnErrorLetterAndIncorrect = Config.stopOnError === "letter" && !correctSoFar; const stopOnErrorWordAndIncorrect = diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index c080c0dee8d2..31af5a3135e9 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -15,6 +15,7 @@ import * as TestState from "../../test/test-state"; import { activeWordIndex } from "../../test/test-state"; import { areAllTestWordsGenerated } from "../../test/test-logic"; import { getCurrentInput } from "../../test/events/data"; +import { removeTrailingSeparator } from "../../utils/strings"; const inputEl = getInputElement(); @@ -129,7 +130,8 @@ inputEl.addEventListener("input", async (event) => { const inputPlusComposition = getCurrentInput() + (CompositionState.getData() ?? ""); const inputPlusCompositionIsCorrect = - TestWords.words.getCurrentText() === inputPlusComposition; + removeTrailingSeparator(TestWords.words.getCurrentText()) === + inputPlusComposition; // composition quick end // if the user typed the entire word correctly but is still in composition diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index e05b4a3320d3..68dd7010582d 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -1,4 +1,8 @@ -import { CharCounts, countChars } from "../../utils/strings"; +import { + CharCounts, + countChars, + removeTrailingSeparator, +} from "../../utils/strings"; import { getEventsForWord, getEventsPerWord, getInputFromDom } from "./helpers"; import { calculateWpm } from "../../utils/numbers"; import { roundTo2 } from "@monkeytype/util/numbers"; @@ -357,22 +361,9 @@ function getTargetWord( return ""; } - if (word.endsWith("\n")) { - // for multiline, dont add space - return word; - } - - let wordEnd = ""; - - if (!lastWord) { - wordEnd = " "; - } - - if (eventLog.context.isFunboxWithNospacePropertyActive) { - wordEnd = ""; - } - - return word + wordEnd; + // Target words store their separator as a trailing space. The last word the + // user reached has no committed separator (the test ended), so strip it. + return lastWord ? removeTrailingSeparator(word) : word; } } @@ -891,7 +882,8 @@ export function getMissedWords(eventLog: EventLog): Record { ) { const word = eventLog.context.targetWords[event.data.wordIndex]; if (word === undefined) continue; - missedWords[word] = (missedWords[word] ?? 0) + 1; + const bareWord = removeTrailingSeparator(word); + missedWords[bareWord] = (missedWords[bareWord] ?? 0) + 1; } } diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 28e4f8b834fb..2372bba2c7db 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -64,7 +64,9 @@ async function readAheadHandleKeydown(event: KeyboardEvent): Promise { !isCorrect && (currentInput !== "" || getInputForWord(TestState.activeWordIndex - 1) !== - TestWords.words.getText(TestState.activeWordIndex - 1) || + Strings.removeTrailingSeparator( + TestWords.words.getText(TestState.activeWordIndex - 1) ?? "", + ) || Config.freedomMode) ) { qs("#words")?.addClass("read_ahead_disabled"); diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 27244a9b6452..89d6b1211853 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -1,4 +1,5 @@ import * as TestWords from "./test-words"; +import { removeTrailingSeparator } from "../utils/strings"; import { Config } from "../config/store"; import * as DB from "../db"; import { getActiveTagsPB } from "../collections/tags"; @@ -173,6 +174,12 @@ export function reset(): void { startTimestamp = 0; } +// visible word length (excludes the stored trailing separator space); throws +// when the word index is out of range, which signals the pace caret is out of words +function wordVisibleLength(wordIndex: number): number { + return removeTrailingSeparator(TestWords.words.getText(wordIndex)).length; +} + function incrementLetterIndex(): void { if (settings === null) return; @@ -180,7 +187,7 @@ function incrementLetterIndex(): void { settings.currentLetterIndex++; if ( settings.currentLetterIndex >= - TestWords.words.getText(settings.currentWordIndex).length + 1 + wordVisibleLength(settings.currentWordIndex) + 1 ) { //go to the next word settings.currentLetterIndex = 0; @@ -193,7 +200,7 @@ function incrementLetterIndex(): void { if (settings.currentLetterIndex <= -2) { //go to the previous word settings.currentLetterIndex = - TestWords.words.getText(settings.currentWordIndex - 1).length - 1; + wordVisibleLength(settings.currentWordIndex - 1) - 1; settings.currentWordIndex--; } settings.correction++; @@ -203,7 +210,7 @@ function incrementLetterIndex(): void { settings.currentLetterIndex++; if ( settings.currentLetterIndex >= - TestWords.words.getText(settings.currentWordIndex).length + wordVisibleLength(settings.currentWordIndex) ) { //go to the next word settings.currentLetterIndex = 0; @@ -230,7 +237,7 @@ export function handleSpace(correct: boolean, currentWord: string): void { !Config.blindMode ) { settings.wordsStatus[TestState.activeWordIndex] = undefined; - settings.correction -= currentWord.length + 1; + settings.correction -= removeTrailingSeparator(currentWord).length + 1; } } else { if ( diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index 49a384d8f8bd..bed164a1b354 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -1,4 +1,5 @@ import * as TestWords from "./test-words"; +import { removeTrailingSeparator } from "../utils/strings"; import { showNoticeNotification } from "../states/notifications"; import { Config } from "../config/store"; @@ -63,7 +64,9 @@ export function init( let sortableMissedBiwords: [string, string, number][] = []; if (missed === "biwords") { for (let i = 0; i < TestWords.words.length; i++) { - const missedWord = TestWords.words.getText(i); + const missedWord = removeTrailingSeparator( + TestWords.words.getText(i) ?? "", + ); const missedWordCount = missedWords[missedWord]; if (missedWordCount !== undefined) { if (i === 0) { @@ -71,7 +74,7 @@ export function init( } else { sortableMissedBiwords.push([ missedWord, - TestWords.words.getText(i - 1), + removeTrailingSeparator(TestWords.words.getText(i - 1) ?? ""), missedWordCount, ]); } diff --git a/frontend/src/ts/test/replay-ui.ts b/frontend/src/ts/test/replay-ui.ts index ac0e43ada6f6..ca90b96b6ab2 100644 --- a/frontend/src/ts/test/replay-ui.ts +++ b/frontend/src/ts/test/replay-ui.ts @@ -9,6 +9,7 @@ import { getInputForWord, } from "./events/data"; import { getInputHistory, getWpmHistory } from "./events/stats"; +import { removeTrailingSeparator } from "../utils/strings"; type ReplayAction = | "correctLetter" @@ -41,7 +42,7 @@ const replayEl = qsr(".pageTest #resultReplay"); function getWordsList(): string[] { if (Config.mode === "zen") return getInputHistory(buildEventLog()); - return TestWords.words.list.slice(); + return TestWords.words.list.map(removeTrailingSeparator); } function deriveReplayActions(): Replay[] { @@ -59,7 +60,9 @@ function deriveReplayActions(): Replay[] { const target = Config.mode === "zen" ? typed - : TestWords.words.getText(prevWordIndex); + : removeTrailingSeparator( + TestWords.words.getText(prevWordIndex) ?? "", + ); const correct = typed === target; actions.push({ action: correct ? "submitCorrectWord" : "submitErrorWord", diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index faf53594d367..b656cbfdf827 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -588,6 +588,7 @@ async function init(): Promise { } TestWords.setHasNumbers(hasNumbers); + TestWords.words.setNospace(isFunboxActiveWithProperty("nospace")); setWordsHaveTab(wordsHaveTab); setWordsHaveNewline(wordsHaveNewline); @@ -713,11 +714,19 @@ export async function addWord(): Promise { } try { + const prevWord = TestWords.words.getText(TestWords.words.length - 1) as + | string + | undefined; + const prevWord2 = TestWords.words.getText(TestWords.words.length - 2) as + | string + | undefined; const randomWord = await WordsGenerator.getNextWord( TestWords.words.length, bound, - TestWords.words.getText(TestWords.words.length - 1), - TestWords.words.getText(TestWords.words.length - 2), + prevWord !== undefined ? Strings.removeTrailingSeparator(prevWord) : "", + prevWord2 !== undefined + ? Strings.removeTrailingSeparator(prevWord2) + : undefined, ); TestWords.words.push(randomWord.word, randomWord.sectionIndex); @@ -1079,7 +1088,12 @@ export async function finish(difficultyFailed = false): Promise { const lastWordInputLength = history[wordIndex]?.length ?? 0; - if (lastWordInputLength < TestWords.words.getText(wordIndex).length) { + if ( + lastWordInputLength < + Strings.removeTrailingSeparator( + TestWords.words.getText(wordIndex) ?? "", + ).length + ) { historyLength--; } diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 3035080af0c3..fdd084bedf12 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -378,7 +378,10 @@ function buildWordHTML(word: string, wordIndex: number): string { let retval = `
`; const funbox = findSingleActiveFunboxWithFunction("getWordHtml"); - const chars = Strings.splitIntoCharacters(word); + // the stored trailing separator space is not rendered as a letter + const chars = Strings.splitIntoCharacters( + Strings.removeTrailingSeparator(word), + ); for (const char of chars) { if (funbox) { retval += funbox.functions.getWordHtml(char, true); @@ -736,7 +739,10 @@ export async function updateWordLetters({ `test-ui.updateWordLetters.${wordIndex}`, async () => { pendingWordData.delete(wordIndex); - const currentWord = TestWords.words.getText(wordIndex); + // strip the stored trailing separator space; it isn't rendered as a letter + const currentWord = Strings.removeTrailingSeparator( + TestWords.words.getText(wordIndex) ?? "", + ); if (!currentWord && Config.mode !== "zen") return; let ret = ""; const wordAtIndex = getWordElement(wordIndex); @@ -1333,7 +1339,9 @@ async function loadWordsHistory(): Promise { for (let i = 0; i < inputHistoryLength + 2; i++) { const input = inputHistory[i]; const corrected = correctedHistory[i]; - const word = TestWords.words.getText(i) ?? ""; + const word = Strings.removeTrailingSeparator( + TestWords.words.getText(i) ?? "", + ); const koreanRegex = /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/; const containsKorean = diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index b8ddae67d6ff..b5203fbaff80 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -4,11 +4,18 @@ class Words { public list: string[]; public sectionIndexList: number[]; public length: number; + // when true, words are not separated by a space (e.g. nospace funbox / CJK) + private nospace: boolean; constructor() { this.list = []; this.sectionIndexList = []; this.length = 0; + this.nospace = false; + } + + setNospace(tf: boolean): void { + this.nospace = tf; } getText(i?: undefined, raw?: boolean): string[]; @@ -31,6 +38,15 @@ class Words { return this.list[this.list.length - 1] as string; } push(word: string, sectionIndex: number): void { + // The word separator is stored as a trailing space on the preceding word. + // A word stays bare until another word is appended after it, so the final + // word never gets a separator. Newline-terminated words and nospace mode + // use no space separator. + const prevIndex = this.list.length - 1; + const prev = this.list[prevIndex]; + if (prev !== undefined && !this.nospace && !prev.endsWith("\n")) { + this.list[prevIndex] = `${prev} `; + } this.list.push(word); this.sectionIndexList.push(sectionIndex); this.length = this.list.length; diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index 0a455a363e6f..1c29caf467a5 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -357,6 +357,19 @@ export function toHex(buffer: ArrayBuffer): string { return hashHex; } +/** + * Removes the trailing separator space from a target word. Target words store + * the word separator as a trailing space (see test-words.ts); this strips that + * single space to get the bare/visible word. Words ending in a newline, the + * final word, and nospace-funbox words have no trailing space, so this is a + * no-op for them. + * @param word The target word. + * @returns The word without its trailing separator space. + */ +export function removeTrailingSeparator(word: string): string { + return word.endsWith(" ") ? word.slice(0, -1) : word; +} + /** * Checks if a character is a directly typable space character on a standard keyboard. * These are space characters that can be typed without special input methods or copy-pasting. From c0b526abc303bb43ad1cc8134b109f25f244ba05 Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 23 Jun 2026 21:41:08 +0200 Subject: [PATCH 02/40] dont use test words --- frontend/src/ts/elements/caret.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index 965f1fb6c4fc..7a0821b3fff8 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -1,8 +1,7 @@ import { CaretStyle } from "@monkeytype/schemas/configs"; import { Config } from "../config/store"; -import * as TestWords from "../test/test-words"; import { getTotalInlineMargin } from "../utils/misc"; -import { isWordRightToLeft, removeTrailingSeparator } from "../utils/strings"; +import { isWordRightToLeft } from "../utils/strings"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { EasingParam, JSAnimation } from "animejs"; import { ElementWithUtils, qsr } from "../utils/dom"; @@ -287,10 +286,13 @@ export class Caret { const word = wordsCache.qs( `.word[data-wordindex="${options.wordIndex}"]`, ); - // strip the stored trailing separator space; it isn't rendered as a letter - const wordText = removeTrailingSeparator( - TestWords.words.getText(options.wordIndex) ?? "", - ); + + const wordText = + word + ?.qsa("letter") + .map((l) => l.native.textContent ?? "") + .join("") ?? ""; + const wordLength = Array.from(wordText).length; // caret can be either on the left side of the target letter or the right From 6d9840735a323794ce6bf8a1ecccc642a1d89f6c Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 23 Jun 2026 21:59:17 +0200 Subject: [PATCH 03/40] update --- frontend/src/ts/input/handlers/before-delete.ts | 5 +---- frontend/src/ts/input/helpers/word-navigation.ts | 5 ++++- frontend/src/ts/test/events/data.ts | 4 +--- frontend/src/ts/test/funbox/funbox-functions.ts | 4 +--- frontend/src/ts/test/replay-ui.ts | 4 +--- 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/frontend/src/ts/input/handlers/before-delete.ts b/frontend/src/ts/input/handlers/before-delete.ts index cbb852a28e2f..b237abf4b2d4 100644 --- a/frontend/src/ts/input/handlers/before-delete.ts +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -5,7 +5,6 @@ import { getInputElementValue } from "../input-element"; import * as TestUI from "../../test/test-ui"; import { isAwaitingNextWord } from "../state"; import { getInputForWord } from "../../test/events/data"; -import { removeTrailingSeparator } from "../../utils/strings"; export function onBeforeDelete(event: InputEvent): void { if (!TestState.isActive) { @@ -53,9 +52,7 @@ export function onBeforeDelete(event: InputEvent): void { const confidence = Config.confidenceMode; const previousWordCorrect = getInputForWord(TestState.activeWordIndex - 1) === - removeTrailingSeparator( - TestWords.words.getText(TestState.activeWordIndex - 1) ?? "", - ); + (TestWords.words.getText(TestState.activeWordIndex - 1) ?? ""); if (confidence === "on" && inputIsEmpty && !previousWordCorrect) { event.preventDefault(); diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index 947d77541858..f93108ccbd6e 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -15,6 +15,7 @@ import { setAwaitingNextWord } from "../state"; import { DeleteInputType } from "./input-type"; import { getWordBurst } from "../../test/events/stats"; import { buildEventLog, getInputForWord } from "../../test/events/data"; +import { removeTrailingSeparator } from "../../utils/strings"; type GoToNextWordParams = { correctInsert: boolean; @@ -104,7 +105,9 @@ export function goToPreviousWord( if (inputType === "deleteWordBackward") { setInputElementValue(""); } else if (inputType === "deleteContentBackward") { - const word = getInputForWord(TestState.activeWordIndex); + const word = removeTrailingSeparator( + getInputForWord(TestState.activeWordIndex), + ); if (nospaceEnabled) { setInputElementValue(word.slice(0, -1)); } else if (word.endsWith("\n")) { diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index 91884985bec0..976e14a5f2e3 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -241,9 +241,7 @@ export function getCurrentInputForDisplay(): string { } export function getInputForWord(wordIndex: number): string { - return getInputFromDom( - getEventsForWord(getAllTestEvents(), wordIndex), - ).trimEnd(); + return getInputFromDom(getEventsForWord(getAllTestEvents(), wordIndex)); } export function cleanupData(): void { diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 2372bba2c7db..d409f5fb6546 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -64,9 +64,7 @@ async function readAheadHandleKeydown(event: KeyboardEvent): Promise { !isCorrect && (currentInput !== "" || getInputForWord(TestState.activeWordIndex - 1) !== - Strings.removeTrailingSeparator( - TestWords.words.getText(TestState.activeWordIndex - 1) ?? "", - ) || + (TestWords.words.getText(TestState.activeWordIndex - 1) ?? "") || Config.freedomMode) ) { qs("#words")?.addClass("read_ahead_disabled"); diff --git a/frontend/src/ts/test/replay-ui.ts b/frontend/src/ts/test/replay-ui.ts index ca90b96b6ab2..f16c58310745 100644 --- a/frontend/src/ts/test/replay-ui.ts +++ b/frontend/src/ts/test/replay-ui.ts @@ -60,9 +60,7 @@ function deriveReplayActions(): Replay[] { const target = Config.mode === "zen" ? typed - : removeTrailingSeparator( - TestWords.words.getText(prevWordIndex) ?? "", - ); + : (TestWords.words.getText(prevWordIndex) ?? ""); const correct = typed === target; actions.push({ action: correct ? "submitCorrectWord" : "submitErrorWord", From f558b5d699dd827605c326ac9bb1adf871c0ef4f Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 23 Jun 2026 23:20:42 +0200 Subject: [PATCH 04/40] cleanup --- .../ts/input/handlers/before-insert-text.ts | 10 +-- frontend/src/ts/input/handlers/insert-text.ts | 47 ++++--------- frontend/src/ts/input/helpers/validation.ts | 69 +++++++------------ 3 files changed, 42 insertions(+), 84 deletions(-) diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 54da3a2e5063..1bf2ee5cb618 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -7,7 +7,6 @@ import { isFunboxActiveWithProperty } from "../../test/funbox/list"; import { isSpace, removeTrailingSeparator } from "../../utils/strings"; import { getInputElementValue } from "../input-element"; import { isAwaitingNextWord } from "../state"; -import { shouldInsertSpaceCharacter } from "../helpers/validation"; import * as SlowTimer from "../../legacy-states/slow-timer"; import { wordsHaveNewline } from "../../states/test"; @@ -31,11 +30,6 @@ export function onBeforeInsertText(data: string): boolean { const { inputValue } = getInputElementValue(); const dataIsSpace = isSpace(data); - const shouldInsertSpaceAsCharacter = shouldInsertSpaceCharacter({ - data, - inputValue, - targetWord: TestWords.words.getCurrentText(), - }); //prevent space from being inserted if input is empty //allow if strict space is enabled @@ -62,7 +56,7 @@ export function onBeforeInsertText(data: string): boolean { const inputLimit = Config.mode === "zen" ? 30 : TestWords.words.getCurrentText().length + 20; const overLimit = getCurrentInput().length >= inputLimit; - if (overLimit && (shouldInsertSpaceAsCharacter === true || !dataIsSpace)) { + if (overLimit && !dataIsSpace) { console.error("Hitting word limit"); return true; } @@ -80,7 +74,7 @@ export function onBeforeInsertText(data: string): boolean { !Config.blindMode && !Config.hideExtraLetters && inputIsLongerThanOrEqualToWord && - (shouldInsertSpaceAsCharacter === true || !dataIsSpace) && + !dataIsSpace && Config.mode !== "zen" ) { // make sure to only check this when really necessary diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index e35e861a0b0a..7bbc90718adb 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -35,11 +35,7 @@ import { import { showNoticeNotification } from "../../states/notifications"; import { goToNextWord } from "../helpers/word-navigation"; import { onBeforeInsertText } from "./before-insert-text"; -import { - isCharCorrect, - isWordCorrect, - shouldInsertSpaceCharacter, -} from "../helpers/validation"; +import { shouldGoToNextWord, isCharCorrect } from "../helpers/validation"; import { getCurrentInput, logTestEvent } from "../../test/events/data"; const charOverrides = new Map([ @@ -150,12 +146,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const wordIndex = TestState.activeWordIndex; const charIsSpace = isSpace(data); const charIsNewline = data === "\n"; - const shouldInsertSpace = - shouldInsertSpaceCharacter({ - data, - inputValue: testInput, - targetWord: currentWord, - }) === true; const correctShiftUsed = Config.oppositeShiftMode === "off" ? null : isCorrectShiftUsed(); @@ -166,7 +156,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { data, currentWord[(testInput + data).length - 1] ?? "", ); - const charCorrect = + const correct = funboxCorrect ?? isCharCorrect({ data, @@ -179,21 +169,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const noSpaceForce = isFunboxActiveWithProperty("nospace") && (testInput + data).length === TestWords.words.getCurrentText().length; - // does this input try to move to the next word (before removeLastChar can block it) - const goingToNextWord = - ((charIsSpace || charIsNewline) && !shouldInsertSpace) || noSpaceForce; - - // when moving to the next word, correctness is word-level (whether the whole - // word — including its trailing separator — was typed correctly) - const correct = goingToNextWord - ? (funboxCorrect ?? - isWordCorrect({ - data, - inputValue: testInput, - targetWord: currentWord, - correctShiftUsed, - })) - : charCorrect; // handing cases where last char needs to be removed // this is here and not in beforeInsertText because we want to penalize for incorrect spaces @@ -207,6 +182,15 @@ export async function onInsertText(options: OnInsertTextParams): Promise { removeLastChar = true; } + const goingToNextWord = + (charIsSpace || charIsNewline || noSpaceForce) && + !removeLastChar && + shouldGoToNextWord({ + data, + inputValue: testInput, + targetWord: currentWord, + }); + if (!charIsSpace && correctShiftUsed === false) { removeLastChar = true; visualInputOverride = undefined; @@ -221,9 +205,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { resetIncorrectShiftsInARow(); } - // stop-on-error and opposite shift mode can block navigation, so this is derived after removeLastChar - const shouldGoToNextWord = goingToNextWord && !removeLastChar; - if (Config.keymapMode === "react") { flash(data, correct); } @@ -249,7 +230,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { inputStopped: removeLastChar ? true : undefined, // inputValue is captured from the input element after this event (before goToNextWord clears it). inputValue: inputValueAfterEvent, - commitsWord: shouldGoToNextWord ? true : undefined, + commitsWord: goingToNextWord ? true : undefined, lastWord: wordIndex === TestWords.words.length - 1 ? true : undefined, }); @@ -263,7 +244,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // going to next word let increasedWordIndex: null | boolean = null; let lastBurst: null | number = null; - if (shouldGoToNextWord) { + if (goingToNextWord) { const result = await goToNextWord({ correctInsert: commitCorrect, isCompositionEnding: isCompositionEnding === true, @@ -321,7 +302,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { TestLogic.fail("min burst"); } else if ( checkIfFinished({ - shouldGoToNextWord, + shouldGoToNextWord: goingToNextWord, testInputWithData: testInput + data, currentWord, allWordsTyped: wordIndex >= TestWords.words.length - 1, diff --git a/frontend/src/ts/input/helpers/validation.ts b/frontend/src/ts/input/helpers/validation.ts index 89d11afbeeb1..6565e7f8d4a3 100644 --- a/frontend/src/ts/input/helpers/validation.ts +++ b/frontend/src/ts/input/helpers/validation.ts @@ -1,5 +1,5 @@ import { Config } from "../../config/store"; -import { isSpace, removeTrailingSeparator } from "../../utils/strings"; +import { isSpace } from "../../utils/strings"; /** * Check if the input data is correct @@ -30,60 +30,43 @@ export function isCharCorrect(options: { } /** - * Check if the input data is correct + * Check if the input data should move to the next word * @param options - Options object - * @param options.inputValue - Current input value (use getCurrentInput(), not input element value) + * @param options.data - Input data + * @param options.inputValue - Current input value * @param options.targetWord - Target word - * @param options.correctShiftUsed - Whether the correct shift state was used. Null means disabled */ -export function isWordCorrect(options: { +export function shouldGoToNextWord(options: { data: string; inputValue: string; targetWord: string; - correctShiftUsed: boolean | null; //null means disabled }): boolean { - const { data, inputValue, targetWord, correctShiftUsed } = options; + const { inputValue, targetWord, data } = options; + + if (!isSpace(data)) { + return false; + } if (Config.mode === "zen") return true; - if (correctShiftUsed === false) return false; - // The committing separator (space/newline) is part of the target word, so the - // typed control char completes it directly. - return inputValue + data === targetWord; -} + //strict space + if ( + inputValue.length === 0 && + (Config.strictSpace || Config.difficulty !== "normal") + ) { + return false; + } -/** - * Determines if a space character should be inserted as a character, or act - * as a "control character" (moving to the next word) - * @param options - Options object - * @param options.data - Input data - * @param options.inputValue - Current input value (use getCurrentInput(), not input element value) - * @param options.targetWord - Target word - * @returns Boolean if data is space, null if not - */ -export function shouldInsertSpaceCharacter(options: { - data: string; - inputValue: string; - targetWord: string; -}): boolean | null { - const { data, inputValue, targetWord } = options; - if (!isSpace(data)) { - return null; + const correct = inputValue + data === targetWord; + + //stop on error + if (Config.stopOnError === "word" && !correct) { + return false; } - if (Config.mode === "zen") { + + if (Config.stopOnError === "letter" && !correct) { return false; } - // correct so far means the full visible word has been typed correctly (the - // separator is the trailing space stored on the target word) - const correctSoFar = inputValue === removeTrailingSeparator(targetWord); - const stopOnErrorLetterAndIncorrect = - Config.stopOnError === "letter" && !correctSoFar; - const stopOnErrorWordAndIncorrect = - Config.stopOnError === "word" && !correctSoFar; - const strictSpace = - inputValue.length === 0 && - (Config.strictSpace || Config.difficulty !== "normal"); - return ( - stopOnErrorLetterAndIncorrect || stopOnErrorWordAndIncorrect || strictSpace - ); + + return true; } From ab9264f763601dfff903db8a2e6c225029acdf5a Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 23 Jun 2026 23:31:50 +0200 Subject: [PATCH 05/40] fix tests --- .../input/helpers/validation.spec.ts | 102 ++++++++++-------- frontend/__tests__/test/events/stats.spec.ts | 5 +- frontend/src/ts/input/helpers/validation.ts | 2 +- 3 files changed, 62 insertions(+), 47 deletions(-) diff --git a/frontend/__tests__/input/helpers/validation.spec.ts b/frontend/__tests__/input/helpers/validation.spec.ts index 37f3ec01f6d9..0042df981174 100644 --- a/frontend/__tests__/input/helpers/validation.spec.ts +++ b/frontend/__tests__/input/helpers/validation.spec.ts @@ -1,8 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; import { isCharCorrect, - isWordCorrect, - shouldInsertSpaceCharacter, + shouldGoToNextWord, } from "../../../src/ts/input/helpers/validation"; import { __testing } from "../../../src/ts/config/testing"; import * as FunboxList from "../../../src/ts/test/funbox/list"; @@ -98,33 +97,37 @@ describe("isCharCorrect", () => { }); }); - describe("Space Handling at the end of a word", () => { + describe("Separator at the end of a word", () => { + // target words store their separator as a trailing space/newline; typing + // that separator at the separator position is a correct char regardless of + // whether the preceding letters were correct (word-level correctness is + // derived from the per-letter events elsewhere) it.each([ - ["returns true at the end of a correct word", " ", "word", "word ", true], + ["space separator at the correct position", " ", "word", "word ", true], [ - "returns false at the end of an incorrect word", + "space separator is correct even after a wrong letter", " ", "worx", "word ", - false, + true, ], [ - "returns true when committing a word with a newline", + "newline separator at the correct position", "\n", "word", "word\n", true, ], [ - "returns false when committing an incorrect word with a newline", + "newline separator is correct even after a wrong letter", "\n", "xord", "word\n", - false, + true, ], ])("%s", (_desc, char, input, word, expected) => { expect( - isWordCorrect({ + isCharCorrect({ data: char, inputValue: input, targetWord: word, @@ -155,7 +158,8 @@ describe("isCharCorrect", () => { }); }); -describe("shouldInsertSpaceCharacter", () => { +describe("shouldGoToNextWord", () => { + // target words store their separator as a trailing space beforeEach(() => { replaceConfig({ mode: "time", @@ -169,124 +173,134 @@ describe("shouldInsertSpaceCharacter", () => { replaceConfig({}); }); - it("returns null if data is not a space", () => { + it("returns false if data is not a space or newline", () => { expect( - shouldInsertSpaceCharacter({ + shouldGoToNextWord({ data: "a", inputValue: "test", - targetWord: "test", + targetWord: "test ", }), - ).toBe(null); + ).toBe(false); }); - it("returns false in zen mode", () => { + it("returns true in zen mode", () => { replaceConfig({ mode: "zen" }); expect( - shouldInsertSpaceCharacter({ + shouldGoToNextWord({ data: " ", inputValue: "test", - targetWord: "test", + targetWord: "test ", }), - ).toBe(false); + ).toBe(true); + }); + + it("returns true when committing a word with a newline", () => { + expect( + shouldGoToNextWord({ + data: "\n", + inputValue: "word", + targetWord: "word\n", + }), + ).toBe(true); }); describe("Logic Checks", () => { it.each([ // Standard behavior (submit word) { - desc: "submit correct word", + desc: "go to next word on correct word", inputValue: "hello", - targetWord: "hello", + targetWord: "hello ", config: { stopOnError: "off", strictSpace: false, difficulty: "normal", }, - expected: false, + expected: true, }, { - desc: "submit incorrect word (stopOnError off)", + desc: "go to next word on incorrect word (stopOnError off)", inputValue: "hel", - targetWord: "hello", + targetWord: "hello ", config: { stopOnError: "off", strictSpace: false, difficulty: "normal", }, - expected: false, + expected: true, }, // Stop on error { - desc: "insert space if incorrect (stopOnError letter)", + desc: "stay on incorrect word (stopOnError letter)", inputValue: "hel", - targetWord: "hello", + targetWord: "hello ", config: { stopOnError: "letter", strictSpace: false, difficulty: "normal", }, - expected: true, + expected: false, }, { - desc: "insert space if incorrect (stopOnError word)", + desc: "stay on incorrect word (stopOnError word)", inputValue: "hel", - targetWord: "hello", + targetWord: "hello ", config: { stopOnError: "word", strictSpace: false, difficulty: "normal", }, - expected: true, + expected: false, }, { - desc: "submit if correct (stopOnError letter)", + desc: "go to next word on correct word (stopOnError letter)", inputValue: "hello", - targetWord: "hello", + targetWord: "hello ", config: { stopOnError: "letter", strictSpace: false, difficulty: "normal", }, - expected: false, + expected: true, }, // Strict space / Difficulty { - desc: "insert space if empty input (strictSpace on)", + desc: "stay on empty input (strictSpace on)", inputValue: "", - targetWord: "hello", + targetWord: "hello ", config: { stopOnError: "off", strictSpace: true, difficulty: "normal", }, - expected: true, + expected: false, }, { - desc: "insert space if empty input (difficulty not normal - expert or master)", + desc: "stay on empty input (difficulty not normal - expert or master)", inputValue: "", - targetWord: "hello", + targetWord: "hello ", config: { stopOnError: "off", strictSpace: false, difficulty: "expert", }, - expected: true, + expected: false, }, { - desc: "submit if not empty input (strictSpace on)", + desc: "go to next word on non-empty input (strictSpace on)", inputValue: "h", - targetWord: "hello", + targetWord: "hello ", config: { stopOnError: "off", strictSpace: true, difficulty: "normal", }, - expected: false, + expected: true, }, ])("$desc", ({ inputValue, targetWord, config, expected }) => { replaceConfig(config as any); expect( - shouldInsertSpaceCharacter({ + shouldGoToNextWord({ data: " ", inputValue, targetWord, diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index ebeff371d6e1..cc1805df8a33 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -1165,8 +1165,9 @@ describe("stats.ts", () => { it("credits a word committed with an IME full-width space", () => { // Japanese IME commits words with the ideographic space U+3000, while the - // target word separator is a regular space — normalize so it still counts - TestWords.list.push("しり", "かこ"); + // target word separator is a regular space — normalize so it still counts. + // Stored words carry the separator as a trailing space (last word is bare). + TestWords.list.push("しり ", "かこ"); (TestState as { activeWordIndex: number }).activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); diff --git a/frontend/src/ts/input/helpers/validation.ts b/frontend/src/ts/input/helpers/validation.ts index 6565e7f8d4a3..d895e821ade5 100644 --- a/frontend/src/ts/input/helpers/validation.ts +++ b/frontend/src/ts/input/helpers/validation.ts @@ -43,7 +43,7 @@ export function shouldGoToNextWord(options: { }): boolean { const { inputValue, targetWord, data } = options; - if (!isSpace(data)) { + if (!isSpace(data) && data !== "\n") { return false; } From c65bf3c26ac918018dd26943578ce1aee5b07e13 Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 23 Jun 2026 23:38:36 +0200 Subject: [PATCH 06/40] cleanup --- frontend/src/ts/input/handlers/insert-text.ts | 14 +++++----- frontend/src/ts/test/test-words.ts | 28 +++---------------- 2 files changed, 11 insertions(+), 31 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 7bbc90718adb..704af341f3c6 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -256,13 +256,13 @@ export async function onInsertText(options: OnInsertTextParams): Promise { } /* - Probably a good place to explain what the heck is going on with all these space related variables: - - spaceOrNewLine: did the user input a space or a new line? - - shouldInsertSpace: should space be treated as a character, or should it move us to the next word - monkeytype doesnt actually have space characters in words, so we need this distinction - and also moving to the next word might get blocked by things like stop on error - - shouldGoToNextWord: IF input is space and we DONT insert a space CHARACTER, we will TRY to go to the next word - - increasedWordIndex: the only reason this is here because on the last word we dont move to the next word + Space/word-navigation variables: + - charIsSpace / charIsNewline: was the input a space or a newline? + - goingToNextWord: should this input commit the current word and move on? + The separator is part of the target word (stored as a trailing space), so a + space/newline matches it positionally; navigation can still be blocked by + stop-on-error, strict space, or opposite shift (removeLastChar). + - increasedWordIndex: only set because on the last word we don't move on. */ //this COULD be the next word because we are awaiting goToNextWord diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index b5203fbaff80..07ecc5aefb51 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -18,25 +18,17 @@ class Words { this.nospace = tf; } - getText(i?: undefined, raw?: boolean): string[]; - getText(i: number, raw?: boolean): string; - getText(i?: number, raw = false): string | string[] | undefined { + getText(i?: undefined): string[]; + getText(i: number): string; + getText(i?: number): string | string[] | undefined { if (i === undefined) { return this.list; - } else { - if (raw) { - return this.list[i]?.replace(/[.?!":\-,]/g, "")?.toLowerCase(); - } else { - return this.list[i]; - } } + return this.list[i]; } getCurrentText(): string { return this.list[TestState.activeWordIndex] ?? ""; } - getLast(): string { - return this.list[this.list.length - 1] as string; - } push(word: string, sectionIndex: number): void { // The word separator is stored as a trailing space on the preceding word. // A word stays bare until another word is appended after it, so the final @@ -57,18 +49,6 @@ class Words { this.sectionIndexList = []; this.length = this.list.length; } - clean(): void { - for (const s of this.list) { - if (/ +/.test(s)) { - const id = this.list.indexOf(s); - const tempList = s.split(" "); - this.list.splice(id, 1); - for (let i = 0; i < tempList.length; i++) { - this.list.splice(id + i, 0, tempList[i] as string); - } - } - } - } } export const words = new Words(); From df9d11531ed81f528048ac552f73b2763d32ba85 Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 23 Jun 2026 23:40:27 +0200 Subject: [PATCH 07/40] rename --- frontend/__tests__/input/helpers/fail-or-finish.spec.ts | 8 ++++---- frontend/src/ts/input/handlers/insert-text.ts | 2 +- frontend/src/ts/input/helpers/fail-or-finish.ts | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/__tests__/input/helpers/fail-or-finish.spec.ts b/frontend/__tests__/input/helpers/fail-or-finish.spec.ts index 0d9faaaaf21f..b0dbd906a3a5 100644 --- a/frontend/__tests__/input/helpers/fail-or-finish.spec.ts +++ b/frontend/__tests__/input/helpers/fail-or-finish.spec.ts @@ -322,7 +322,7 @@ describe("checkIfFinished", () => { allWordsTyped: true, testInputWithData: "wo ", currentWord: "word", - shouldGoToNextWord: true, + goingToNextWord: true, expected: true, }, { @@ -336,7 +336,7 @@ describe("checkIfFinished", () => { desc: string; allWordsTyped: boolean; allWordsGenerated?: boolean; - shouldGoToNextWord: boolean; + goingToNextWord: boolean; testInputWithData: string; currentWord: string; config?: Record; @@ -347,7 +347,7 @@ describe("checkIfFinished", () => { ({ allWordsTyped, allWordsGenerated, - shouldGoToNextWord, + goingToNextWord, testInputWithData, currentWord, config, @@ -356,7 +356,7 @@ describe("checkIfFinished", () => { if (config) replaceConfig(config as any); const result = checkIfFinished({ - shouldGoToNextWord, + goingToNextWord, testInputWithData, currentWord, allWordsTyped, diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 704af341f3c6..4a358d5b3170 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -302,7 +302,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { TestLogic.fail("min burst"); } else if ( checkIfFinished({ - shouldGoToNextWord: goingToNextWord, + goingToNextWord, testInputWithData: testInput + data, currentWord, allWordsTyped: wordIndex >= TestWords.words.length - 1, diff --git a/frontend/src/ts/input/helpers/fail-or-finish.ts b/frontend/src/ts/input/helpers/fail-or-finish.ts index 6c096a42c10b..481c9ca2836f 100644 --- a/frontend/src/ts/input/helpers/fail-or-finish.ts +++ b/frontend/src/ts/input/helpers/fail-or-finish.ts @@ -68,21 +68,21 @@ export function checkIfFailedDueToDifficulty(options: { /** * Determines if the test should finish * @param options - Options object - * @param options.shouldGoToNextWord - Should go to next word + * @param options.goingToNextWord - Is this input committing the word and moving on * @param options.testInputWithData - Current test input result (after adding data) * @param options.currentWord - Current target word * @param options.allWordsTyped - Have all words been typed * @returns Boolean if test should finish */ export function checkIfFinished(options: { - shouldGoToNextWord: boolean; + goingToNextWord: boolean; testInputWithData: string; currentWord: string; allWordsTyped: boolean; allWordsGenerated: boolean; }): boolean { const { - shouldGoToNextWord, + goingToNextWord, testInputWithData, currentWord, allWordsTyped, @@ -96,7 +96,7 @@ export function checkIfFinished(options: { if ( allWordsTyped && allWordsGenerated && - (wordIsCorrect || shouldQuickEnd || shouldGoToNextWord) + (wordIsCorrect || shouldQuickEnd || goingToNextWord) ) { return true; } From f8c8955241b682aacf6feaefccdd1eb728b6292e Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Jun 2026 12:01:41 +0200 Subject: [PATCH 08/40] revert --- frontend/src/ts/elements/caret.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index 7a0821b3fff8..76404494d8d8 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -1,10 +1,11 @@ import { CaretStyle } from "@monkeytype/schemas/configs"; import { Config } from "../config/store"; import { getTotalInlineMargin } from "../utils/misc"; -import { isWordRightToLeft } from "../utils/strings"; +import { isWordRightToLeft, removeTrailingSeparator } from "../utils/strings"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { EasingParam, JSAnimation } from "animejs"; import { ElementWithUtils, qsr } from "../utils/dom"; +import * as TestWords from "../test/test-words"; const wordsCache = qsr("#words"); const wordsWrapperCache = qsr("#wordsWrapper"); @@ -287,11 +288,9 @@ export class Caret { `.word[data-wordindex="${options.wordIndex}"]`, ); - const wordText = - word - ?.qsa("letter") - .map((l) => l.native.textContent ?? "") - .join("") ?? ""; + const wordText = removeTrailingSeparator( + TestWords.words.getText(options.wordIndex) ?? "", + ); const wordLength = Array.from(wordText).length; From 8e80756b67b3245f6ea818947fa956195e2a501d Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Jun 2026 12:03:17 +0200 Subject: [PATCH 09/40] fix --- frontend/src/ts/input/helpers/validation.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/ts/input/helpers/validation.ts b/frontend/src/ts/input/helpers/validation.ts index d895e821ade5..1544f5d1f650 100644 --- a/frontend/src/ts/input/helpers/validation.ts +++ b/frontend/src/ts/input/helpers/validation.ts @@ -1,5 +1,4 @@ import { Config } from "../../config/store"; -import { isSpace } from "../../utils/strings"; /** * Check if the input data is correct @@ -43,9 +42,10 @@ export function shouldGoToNextWord(options: { }): boolean { const { inputValue, targetWord, data } = options; - if (!isSpace(data) && data !== "\n") { - return false; - } + // do not do this here, nospace can move to the next word with a letter + // if (!isSpace(data) && data !== "\n") { + // return false; + // } if (Config.mode === "zen") return true; From 2f58b15fc24fc16d32ef329c13bbb15f4ebbf7ab Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Jun 2026 12:19:27 +0200 Subject: [PATCH 10/40] fix --- .../src/ts/input/handlers/before-insert-text.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 1bf2ee5cb618..3127d848902a 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -9,6 +9,7 @@ import { getInputElementValue } from "../input-element"; import { isAwaitingNextWord } from "../state"; import * as SlowTimer from "../../legacy-states/slow-timer"; import { wordsHaveNewline } from "../../states/test"; +import { shouldGoToNextWord } from "../helpers/validation"; /** * Handles logic before inserting text into the input element. @@ -56,7 +57,17 @@ export function onBeforeInsertText(data: string): boolean { const inputLimit = Config.mode === "zen" ? 30 : TestWords.words.getCurrentText().length + 20; const overLimit = getCurrentInput().length >= inputLimit; - if (overLimit && !dataIsSpace) { + if ( + overLimit && + !( + (data === "\n" || isSpace(data)) && + shouldGoToNextWord({ + data, + inputValue: getCurrentInput(), + targetWord: TestWords.words.getCurrentText(), + }) + ) + ) { console.error("Hitting word limit"); return true; } From d00f3fa82ccfd15bae04c7843579f9f82b2110b4 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Jun 2026 12:43:02 +0200 Subject: [PATCH 11/40] refacto --- .../ts/input/handlers/before-insert-text.ts | 13 ++++------ frontend/src/ts/input/handlers/insert-text.ts | 6 ++--- .../src/ts/input/helpers/fail-or-finish.ts | 17 +++++++----- frontend/src/ts/input/helpers/util.ts | 26 +++++++++++++++++++ frontend/src/ts/input/helpers/validation.ts | 8 +++--- 5 files changed, 49 insertions(+), 21 deletions(-) create mode 100644 frontend/src/ts/input/helpers/util.ts diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 3127d848902a..156962db93a1 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -59,14 +59,11 @@ export function onBeforeInsertText(data: string): boolean { const overLimit = getCurrentInput().length >= inputLimit; if ( overLimit && - !( - (data === "\n" || isSpace(data)) && - shouldGoToNextWord({ - data, - inputValue: getCurrentInput(), - targetWord: TestWords.words.getCurrentText(), - }) - ) + !shouldGoToNextWord({ + data, + inputValue: getCurrentInput(), + targetWord: TestWords.words.getCurrentText(), + }) ) { console.error("Hitting word limit"); return true; diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 97f5808e5306..1e3d5e1f834f 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -172,7 +172,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { } const goingToNextWord = - (charIsSpace || charIsNewline || noSpaceForce) && !removeLastChar && shouldGoToNextWord({ data, @@ -274,9 +273,10 @@ export async function onInsertText(options: OnInsertTextParams): Promise { if (!CompositionState.getComposing() && lastInMultiOrSingle) { if ( checkIfFailedDueToDifficulty({ - testInputWithData: testInput + data, + data, + testInput: testInput, + targetWord: currentWord, correct, - spaceOrNewline: charIsSpace || charIsNewline, }) ) { TestLogic.fail("difficulty"); diff --git a/frontend/src/ts/input/helpers/fail-or-finish.ts b/frontend/src/ts/input/helpers/fail-or-finish.ts index 481c9ca2836f..61831819a262 100644 --- a/frontend/src/ts/input/helpers/fail-or-finish.ts +++ b/frontend/src/ts/input/helpers/fail-or-finish.ts @@ -1,5 +1,6 @@ import { Config } from "../../config/store"; import { whorf } from "../../utils/misc"; +import { isCommitCharacter } from "./util"; /** * Check if the test should fail due to minimum burst settings @@ -41,11 +42,12 @@ export function checkIfFailedDueToMinBurst(options: { * @param options.spaceOrNewline - Is the input a space or newline */ export function checkIfFailedDueToDifficulty(options: { - testInputWithData: string; + data: string; + testInput: string; + targetWord: string; correct: boolean; - spaceOrNewline: boolean; }): boolean { - const { testInputWithData, correct, spaceOrNewline } = options; + const { data, testInput, correct, targetWord } = options; // Using space or newline instead of shouldInsertSpace or increasedWordIndex // because we want expert mode to fail no matter if confidence or stop on error is on @@ -53,9 +55,12 @@ export function checkIfFailedDueToDifficulty(options: { const shouldFailDueToExpert = Config.difficulty === "expert" && - !correct && - spaceOrNewline && - testInputWithData.length > 1; + isCommitCharacter({ + data, + inputValue: testInput, + targetWord, + }) && + testInput + data !== targetWord; const shouldFailDueToMaster = Config.difficulty === "master" && !correct; diff --git a/frontend/src/ts/input/helpers/util.ts b/frontend/src/ts/input/helpers/util.ts new file mode 100644 index 000000000000..61f6eacd2730 --- /dev/null +++ b/frontend/src/ts/input/helpers/util.ts @@ -0,0 +1,26 @@ +import { isFunboxActiveWithProperty } from "../../test/funbox/active"; +import { isSpace } from "../../utils/strings"; + +export function isCommitCharacter(options: { + data: string; + inputValue: string; + targetWord: string; +}): boolean { + const { data, inputValue, targetWord } = options; + + if (isSpace(data)) { + return true; + } + + if (data === "\n") { + return true; + } + + const nospace = isFunboxActiveWithProperty("nospace"); + + if (nospace && (inputValue + data).length === targetWord.length) { + return true; + } + + return false; +} diff --git a/frontend/src/ts/input/helpers/validation.ts b/frontend/src/ts/input/helpers/validation.ts index 1544f5d1f650..06a186a0c16c 100644 --- a/frontend/src/ts/input/helpers/validation.ts +++ b/frontend/src/ts/input/helpers/validation.ts @@ -1,4 +1,5 @@ import { Config } from "../../config/store"; +import { isCommitCharacter } from "./util"; /** * Check if the input data is correct @@ -42,10 +43,9 @@ export function shouldGoToNextWord(options: { }): boolean { const { inputValue, targetWord, data } = options; - // do not do this here, nospace can move to the next word with a letter - // if (!isSpace(data) && data !== "\n") { - // return false; - // } + const isCommit = isCommitCharacter(options); + + if (!isCommit) return false; if (Config.mode === "zen") return true; From 79aed3d2a97905b8f7c30d321e77176f58635b30 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Jun 2026 12:48:42 +0200 Subject: [PATCH 12/40] blindly following instructions --- frontend/src/ts/input/handlers/insert-text.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 1e3d5e1f834f..f56538519f91 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -13,7 +13,6 @@ import { } from "../helpers/fail-or-finish"; import { areCharactersVisuallyEqual, - isSpace, removeLanguageSize, } from "../../utils/strings"; import * as TestState from "../../test/test-state"; @@ -141,8 +140,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const lastInMultiOrSingle = lastInMultiIndex === true || lastInMultiIndex === undefined; const wordIndex = TestState.activeWordIndex; - const charIsSpace = isSpace(data); - const charIsNewline = data === "\n"; const correctShiftUsed = Config.oppositeShiftMode === "off" ? null : isCorrectShiftUsed(); @@ -179,7 +176,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { targetWord: currentWord, }); - if (!charIsSpace && correctShiftUsed === false) { + if (correctShiftUsed === false) { removeLastChar = true; visualInputOverride = undefined; incrementIncorrectShiftsInARow(); @@ -236,7 +233,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const result = await goToNextWord({ correctInsert: commitCorrect, isCompositionEnding: isCompositionEnding === true, - zenNewline: charIsNewline && Config.mode === "zen", + zenNewline: data === "\n" && Config.mode === "zen", now, }); lastBurst = result.lastBurst; From 53aaba2c00e864ca68b0b484f02f661f490102cc Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Jun 2026 12:54:02 +0200 Subject: [PATCH 13/40] yes honey --- frontend/src/ts/input/handlers/insert-text.ts | 8 ++++++++ frontend/src/ts/input/helpers/fail-or-finish.ts | 10 +++------- frontend/src/ts/input/helpers/validation.ts | 8 +++----- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index f56538519f91..049bff2c36ac 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -33,6 +33,7 @@ import { goToNextWord } from "../helpers/word-navigation"; import { onBeforeInsertText } from "./before-insert-text"; import { shouldGoToNextWord, isCharCorrect } from "../helpers/validation"; import { getCurrentInput, logTestEvent } from "../../test/events/data"; +import { isCommitCharacter } from "../helpers/util"; const charOverrides = new Map([ ["…", "..."], @@ -142,6 +143,11 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const wordIndex = TestState.activeWordIndex; const correctShiftUsed = Config.oppositeShiftMode === "off" ? null : isCorrectShiftUsed(); + const isCommit = isCommitCharacter({ + data, + inputValue: testInput, + targetWord: currentWord, + }); // is char correct const correct = isCharCorrect({ @@ -174,6 +180,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { data, inputValue: testInput, targetWord: currentWord, + isCommitCharacter: isCommit, }); if (correctShiftUsed === false) { @@ -274,6 +281,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { testInput: testInput, targetWord: currentWord, correct, + isCommitCharacter: isCommit, }) ) { TestLogic.fail("difficulty"); diff --git a/frontend/src/ts/input/helpers/fail-or-finish.ts b/frontend/src/ts/input/helpers/fail-or-finish.ts index 61831819a262..5b95d81915e0 100644 --- a/frontend/src/ts/input/helpers/fail-or-finish.ts +++ b/frontend/src/ts/input/helpers/fail-or-finish.ts @@ -1,6 +1,5 @@ import { Config } from "../../config/store"; import { whorf } from "../../utils/misc"; -import { isCommitCharacter } from "./util"; /** * Check if the test should fail due to minimum burst settings @@ -46,8 +45,9 @@ export function checkIfFailedDueToDifficulty(options: { testInput: string; targetWord: string; correct: boolean; + isCommitCharacter: boolean; }): boolean { - const { data, testInput, correct, targetWord } = options; + const { data, testInput, correct, targetWord, isCommitCharacter } = options; // Using space or newline instead of shouldInsertSpace or increasedWordIndex // because we want expert mode to fail no matter if confidence or stop on error is on @@ -55,11 +55,7 @@ export function checkIfFailedDueToDifficulty(options: { const shouldFailDueToExpert = Config.difficulty === "expert" && - isCommitCharacter({ - data, - inputValue: testInput, - targetWord, - }) && + isCommitCharacter && testInput + data !== targetWord; const shouldFailDueToMaster = Config.difficulty === "master" && !correct; diff --git a/frontend/src/ts/input/helpers/validation.ts b/frontend/src/ts/input/helpers/validation.ts index 06a186a0c16c..6dc1751e559b 100644 --- a/frontend/src/ts/input/helpers/validation.ts +++ b/frontend/src/ts/input/helpers/validation.ts @@ -1,5 +1,4 @@ import { Config } from "../../config/store"; -import { isCommitCharacter } from "./util"; /** * Check if the input data is correct @@ -40,12 +39,11 @@ export function shouldGoToNextWord(options: { data: string; inputValue: string; targetWord: string; + isCommitCharacter: boolean; }): boolean { - const { inputValue, targetWord, data } = options; + const { inputValue, targetWord, data, isCommitCharacter } = options; - const isCommit = isCommitCharacter(options); - - if (!isCommit) return false; + if (!isCommitCharacter) return false; if (Config.mode === "zen") return true; From a9169e794173691dfed95f00010b220bb2ce6078 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Jun 2026 19:12:41 +0200 Subject: [PATCH 14/40] fix(test): make eager word separators work with plus_zero Words now carry their commit separator as a trailing space emitted by the generator (getNextWord) instead of being added retroactively to the previous word in Words.push. The old retroactive model assumed the next word was always generated before the current one was committed, which is false for plus_zero (toPush:1, zero lookahead): the current word was read bare during live validation, breaking commit, stop-on-error, expert, and quick-end. - strip prev words inside getNextWord so trailing separators don't leak into dedup/punctuation/capitalization feedback - guard the separator append for nospace mode and newline-terminated words - strip the trailing separator from the final word once generation is complete, covering the bulk, section, and lazy addWord paths (final word stays bare) - restore the expert-mode guard so a leading separator (empty input) never fails - move areAllTestWordsGenerated -> areAllWordsGenerated in words-generator Update test-words/validation/fail-or-finish specs to the new contracts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../input/helpers/fail-or-finish.spec.ts | 122 +++++++++++------- .../input/helpers/validation.spec.ts | 6 +- frontend/__tests__/test/test-words.spec.ts | 52 +++++--- frontend/src/ts/input/handlers/insert-text.ts | 3 +- .../src/ts/input/helpers/fail-or-finish.ts | 2 + frontend/src/ts/input/listeners/input.ts | 4 +- frontend/src/ts/test/test-logic.ts | 40 ++---- frontend/src/ts/test/test-words.ts | 25 ++-- frontend/src/ts/test/words-generator.ts | 32 +++++ 9 files changed, 174 insertions(+), 112 deletions(-) diff --git a/frontend/__tests__/input/helpers/fail-or-finish.spec.ts b/frontend/__tests__/input/helpers/fail-or-finish.spec.ts index b0dbd906a3a5..7e483aa7bd4b 100644 --- a/frontend/__tests__/input/helpers/fail-or-finish.spec.ts +++ b/frontend/__tests__/input/helpers/fail-or-finish.spec.ts @@ -6,7 +6,6 @@ import { } from "../../../src/ts/input/helpers/fail-or-finish"; import { __testing } from "../../../src/ts/config/testing"; import * as Misc from "../../../src/ts/utils/misc"; -import * as TestLogic from "../../../src/ts/test/test-logic"; import * as Strings from "../../../src/ts/utils/strings"; const { replaceConfig } = __testing; @@ -20,10 +19,6 @@ vi.mock("../../../src/ts/utils/misc", async (importOriginal) => { }; }); -vi.mock("../../../src/ts/test/test-logic", () => ({ - areAllTestWordsGenerated: vi.fn(), -})); - vi.mock("../../../src/ts/utils/strings", () => ({ isSpace: vi.fn(), })); @@ -38,8 +33,6 @@ describe("checkIfFailedDueToMinBurst", () => { }); // oxlint-disable-next-line typescript/no-unsafe-call (Misc.whorf as any).mockReturnValue(0); - // oxlint-disable-next-line typescript/no-unsafe-call - (TestLogic.areAllTestWordsGenerated as any).mockReturnValue(true); }); afterAll(() => { @@ -139,16 +132,20 @@ describe("checkIfFailedDueToDifficulty", () => { desc: "zen mode, master - never fails", config: { mode: "zen", difficulty: "master" }, correct: false, - spaceOrNewline: true, - input: "hello", + data: " ", + testInput: "hello", + targetWord: "hello ", + isCommitCharacter: true, expected: false, }, { desc: "zen mode - never fails", config: { mode: "zen", difficulty: "normal" }, correct: false, - spaceOrNewline: true, - input: "hello", + data: " ", + testInput: "hello", + targetWord: "hello ", + isCommitCharacter: true, expected: false, }, // @@ -156,32 +153,40 @@ describe("checkIfFailedDueToDifficulty", () => { desc: "normal typing incorrect- never fails", config: { difficulty: "normal" }, correct: false, - spaceOrNewline: false, - input: "hello", + data: "h", + testInput: "hell", + targetWord: "hello", + isCommitCharacter: false, expected: false, }, { desc: "normal typing space incorrect - never fails", config: { difficulty: "normal" }, correct: false, - spaceOrNewline: true, - input: "hello", + data: " ", + testInput: "hell", + targetWord: "hello ", + isCommitCharacter: true, expected: false, }, { desc: "normal typing correct - never fails", config: { difficulty: "normal" }, correct: true, - spaceOrNewline: false, - input: "hello", + data: "o", + testInput: "hell", + targetWord: "hello", + isCommitCharacter: false, expected: false, }, { desc: "normal typing space correct - never fails", config: { difficulty: "normal" }, correct: true, - spaceOrNewline: true, - input: "hello", + data: " ", + testInput: "hello", + targetWord: "hello ", + isCommitCharacter: true, expected: false, }, // @@ -189,32 +194,40 @@ describe("checkIfFailedDueToDifficulty", () => { desc: "expert - fail if incorrect space", config: { difficulty: "expert" }, correct: false, - spaceOrNewline: true, - input: "he", + data: " ", + testInput: "he", + targetWord: "hello ", + isCommitCharacter: true, expected: true, }, { desc: "expert - dont fail if space is the first character", config: { difficulty: "expert" }, correct: false, - spaceOrNewline: true, - input: " ", + data: " ", + testInput: "", + targetWord: "hello ", + isCommitCharacter: true, expected: false, }, { desc: "expert: - dont fail if just typing", config: { difficulty: "expert" }, correct: false, - spaceOrNewline: false, - input: "h", + data: "h", + testInput: "hell", + targetWord: "hello", + isCommitCharacter: false, expected: false, }, { desc: "expert: - dont fail if just typing", config: { difficulty: "expert" }, correct: true, - spaceOrNewline: false, - input: "h", + data: "o", + testInput: "hell", + targetWord: "hello", + isCommitCharacter: false, expected: false, }, // @@ -222,43 +235,64 @@ describe("checkIfFailedDueToDifficulty", () => { desc: "master - fail if incorrect char", config: { difficulty: "master" }, correct: false, - spaceOrNewline: false, - input: "h", + data: "h", + testInput: "hell", + targetWord: "hello", + isCommitCharacter: false, expected: true, }, { desc: "master - fail if incorrect first space", config: { difficulty: "master" }, correct: true, - spaceOrNewline: true, - input: " ", + data: " ", + testInput: "", + targetWord: "hello ", + isCommitCharacter: true, expected: false, }, { desc: "master - dont fail if correct char", config: { difficulty: "master" }, correct: true, - spaceOrNewline: false, - input: "a", + data: "a", + testInput: "te", + targetWord: "tea", + isCommitCharacter: false, expected: false, }, { desc: "master - dont fail if correct space", config: { difficulty: "master" }, correct: true, - spaceOrNewline: true, - input: " ", + data: " ", + testInput: "hello", + targetWord: "hello ", + isCommitCharacter: true, expected: false, }, - ])("$desc", ({ config, correct, spaceOrNewline, input, expected }) => { - replaceConfig(config as any); - const result = checkIfFailedDueToDifficulty({ - testInputWithData: input, + ])( + "$desc", + ({ + config, correct, - spaceOrNewline, - }); - expect(result).toBe(expected); - }); + data, + testInput, + targetWord, + isCommitCharacter, + expected, + }) => { + replaceConfig(config as any); + const result = checkIfFailedDueToDifficulty({ + data, + testInput, + targetWord, + correct, + isCommitCharacter, + }); + expect(result).toBe(expected); + }, + ); }); describe("checkIfFinished", () => { @@ -270,8 +304,6 @@ describe("checkIfFinished", () => { }); // oxlint-disable-next-line typescript/no-unsafe-call (Strings.isSpace as any).mockReturnValue(false); - // oxlint-disable-next-line typescript/no-unsafe-call - (TestLogic.areAllTestWordsGenerated as any).mockReturnValue(true); }); afterAll(() => { diff --git a/frontend/__tests__/input/helpers/validation.spec.ts b/frontend/__tests__/input/helpers/validation.spec.ts index 0042df981174..94b966a10634 100644 --- a/frontend/__tests__/input/helpers/validation.spec.ts +++ b/frontend/__tests__/input/helpers/validation.spec.ts @@ -173,12 +173,13 @@ describe("shouldGoToNextWord", () => { replaceConfig({}); }); - it("returns false if data is not a space or newline", () => { + it("returns false when the input is not a commit character", () => { expect( shouldGoToNextWord({ data: "a", inputValue: "test", targetWord: "test ", + isCommitCharacter: false, }), ).toBe(false); }); @@ -190,6 +191,7 @@ describe("shouldGoToNextWord", () => { data: " ", inputValue: "test", targetWord: "test ", + isCommitCharacter: true, }), ).toBe(true); }); @@ -200,6 +202,7 @@ describe("shouldGoToNextWord", () => { data: "\n", inputValue: "word", targetWord: "word\n", + isCommitCharacter: true, }), ).toBe(true); }); @@ -304,6 +307,7 @@ describe("shouldGoToNextWord", () => { data: " ", inputValue, targetWord, + isCommitCharacter: true, }), ).toBe(expected); }); diff --git a/frontend/__tests__/test/test-words.spec.ts b/frontend/__tests__/test/test-words.spec.ts index 84defea31390..b0fc6ad6d95f 100644 --- a/frontend/__tests__/test/test-words.spec.ts +++ b/frontend/__tests__/test/test-words.spec.ts @@ -9,40 +9,50 @@ import { words } from "../../src/ts/test/test-words"; describe("test-words", () => { beforeEach(() => { words.reset(); - words.setNospace(false); }); - describe("push (separator storage)", () => { - it("stores the separator as a trailing space on each non-last word", () => { - words.push("the", 0); - words.push("cat", 0); + describe("push", () => { + // separators are part of the word text (added by the generator); push stores + // words verbatim and does not insert or strip separators + it("appends words verbatim", () => { + words.push("the ", 0); + words.push("cat ", 0); words.push("sat", 0); expect(words.list).toEqual(["the ", "cat ", "sat"]); }); - it("leaves a single word bare", () => { - words.push("hello", 0); - expect(words.list).toEqual(["hello"]); + it("tracks length and section indexes", () => { + words.push("a ", 3); + words.push("b", 5); + expect(words.length).toBe(2); + expect(words.sectionIndexList).toEqual([3, 5]); }); + }); - it("terminates the previous word as new words are appended mid-test", () => { - words.push("a", 0); - expect(words.list).toEqual(["a"]); - words.push("b", 0); - expect(words.list).toEqual(["a ", "b"]); + describe("removeCommitCharacterFromLastWord", () => { + it("strips a trailing space from the last word", () => { + words.push("the ", 0); + words.push("end ", 0); + words.removeCommitCharacterFromLastWord(); + expect(words.list).toEqual(["the ", "end"]); }); - it("does not add a space after a newline-terminated word", () => { + it("strips a trailing newline from the last word", () => { words.push("line\n", 0); - words.push("next", 0); - expect(words.list).toEqual(["line\n", "next"]); + words.removeCommitCharacterFromLastWord(); + expect(words.list).toEqual(["line"]); + }); + + it("leaves a bare last word unchanged", () => { + words.push("the ", 0); + words.push("end", 0); + words.removeCommitCharacterFromLastWord(); + expect(words.list).toEqual(["the ", "end"]); }); - it("adds no separators when nospace is set", () => { - words.setNospace(true); - words.push("猫", 0); - words.push("犬", 0); - expect(words.list).toEqual(["猫", "犬"]); + it("does nothing on an empty list", () => { + expect(() => words.removeCommitCharacterFromLastWord()).not.toThrow(); + expect(words.list).toEqual([]); }); }); }); diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 049bff2c36ac..d77d9f582571 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -34,6 +34,7 @@ import { onBeforeInsertText } from "./before-insert-text"; import { shouldGoToNextWord, isCharCorrect } from "../helpers/validation"; import { getCurrentInput, logTestEvent } from "../../test/events/data"; import { isCommitCharacter } from "../helpers/util"; +import { areAllWordsGenerated } from "../../test/words-generator"; const charOverrides = new Map([ ["…", "..."], @@ -300,7 +301,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { testInputWithData: testInput + data, currentWord, allWordsTyped: wordIndex >= TestWords.words.length - 1, - allWordsGenerated: TestLogic.areAllTestWordsGenerated(), + allWordsGenerated: areAllWordsGenerated(), }) ) { void TestLogic.finish(); diff --git a/frontend/src/ts/input/helpers/fail-or-finish.ts b/frontend/src/ts/input/helpers/fail-or-finish.ts index 5b95d81915e0..4329a33904c2 100644 --- a/frontend/src/ts/input/helpers/fail-or-finish.ts +++ b/frontend/src/ts/input/helpers/fail-or-finish.ts @@ -56,6 +56,8 @@ export function checkIfFailedDueToDifficulty(options: { const shouldFailDueToExpert = Config.difficulty === "expert" && isCommitCharacter && + // a leading separator (empty input) commits nothing and must not fail + testInput.length > 0 && testInput + data !== targetWord; const shouldFailDueToMaster = Config.difficulty === "master" && !correct; diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index 31af5a3135e9..b14f370d93e7 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -13,9 +13,9 @@ 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"; import { getCurrentInput } from "../../test/events/data"; import { removeTrailingSeparator } from "../../utils/strings"; +import { areAllWordsGenerated } from "../../test/words-generator"; const inputEl = getInputElement(); @@ -138,7 +138,7 @@ inputEl.addEventListener("input", async (event) => { // dont wait for them to end the composition manually, just end the test // by dispatching a compositionend which will trigger onInsertText if ( - areAllTestWordsGenerated() && + areAllWordsGenerated() && allWordsTyped && inputPlusCompositionIsCorrect ) { diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index b656cbfdf827..3502aaffdb2f 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -588,7 +588,6 @@ async function init(): Promise { } TestWords.setHasNumbers(hasNumbers); - TestWords.words.setNospace(isFunboxActiveWithProperty("nospace")); setWordsHaveTab(wordsHaveTab); setWordsHaveNewline(wordsHaveNewline); @@ -610,6 +609,10 @@ async function init(): Promise { ); } + if (WordsGenerator.areAllWordsGenerated()) { + TestWords.words.removeCommitCharacterFromLastWord(); + } + if (Config.keymapMode === "next" && Config.mode !== "zen") { highlight( Arrays.nthElementFromArray( @@ -630,7 +633,7 @@ async function init(): Promise { isFunboxActiveWithProperty("reverseDirection"), ); - console.debug("Test initialized with words", generatedWords); + console.debug("Test initialized with words", TestWords.words.list); console.debug( "Test initialized with section indexes", generatedSectionIndexes, @@ -638,25 +641,6 @@ async function init(): Promise { return true; } -export function areAllTestWordsGenerated(): boolean { - return ( - (Config.mode === "words" && - TestWords.words.length >= Config.words && - Config.words > 0) || - (Config.mode === "custom" && - CustomText.getLimitMode() === "word" && - TestWords.words.length >= CustomText.getLimitValue() && - CustomText.getLimitValue() !== 0) || - (Config.mode === "quote" && - TestWords.words.length >= (getCurrentQuote()?.textSplit?.length ?? 0)) || - (Config.mode === "custom" && - CustomText.getLimitMode() === "section" && - WordsGenerator.sectionIndex >= CustomText.getLimitValue() && - WordsGenerator.currentSection.length === 0 && - CustomText.getLimitValue() !== 0) - ); -} - //add word during the test export async function addWord(): Promise { if (Config.mode === "zen") { @@ -678,7 +662,7 @@ export async function addWord(): Promise { console.debug("Not adding word, enough words already"); return; } - if (areAllTestWordsGenerated()) { + if (WordsGenerator.areAllWordsGenerated()) { console.debug("Not adding word, all words generated"); return; } @@ -723,10 +707,8 @@ export async function addWord(): Promise { const randomWord = await WordsGenerator.getNextWord( TestWords.words.length, bound, - prevWord !== undefined ? Strings.removeTrailingSeparator(prevWord) : "", - prevWord2 !== undefined - ? Strings.removeTrailingSeparator(prevWord2) - : undefined, + prevWord ?? "", + prevWord2, ); TestWords.words.push(randomWord.word, randomWord.sectionIndex); @@ -741,6 +723,12 @@ export async function addWord(): Promise { }, ); } + + // strip the trailing commit separator once the final word has been generated + // (covers the section and lazy paths) + if (WordsGenerator.areAllWordsGenerated()) { + TestWords.words.removeCommitCharacterFromLastWord(); + } } type RetrySaving = { diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index 07ecc5aefb51..3f87e8e9e392 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -4,18 +4,11 @@ class Words { public list: string[]; public sectionIndexList: number[]; public length: number; - // when true, words are not separated by a space (e.g. nospace funbox / CJK) - private nospace: boolean; constructor() { this.list = []; this.sectionIndexList = []; this.length = 0; - this.nospace = false; - } - - setNospace(tf: boolean): void { - this.nospace = tf; } getText(i?: undefined): string[]; @@ -30,15 +23,6 @@ class Words { return this.list[TestState.activeWordIndex] ?? ""; } push(word: string, sectionIndex: number): void { - // The word separator is stored as a trailing space on the preceding word. - // A word stays bare until another word is appended after it, so the final - // word never gets a separator. Newline-terminated words and nospace mode - // use no space separator. - const prevIndex = this.list.length - 1; - const prev = this.list[prevIndex]; - if (prev !== undefined && !this.nospace && !prev.endsWith("\n")) { - this.list[prevIndex] = `${prev} `; - } this.list.push(word); this.sectionIndexList.push(sectionIndex); this.length = this.list.length; @@ -49,6 +33,15 @@ class Words { this.sectionIndexList = []; this.length = this.list.length; } + + removeCommitCharacterFromLastWord(): void { + if (this.length === 0) return; + const lastWord = this.list[this.length - 1]; + if (lastWord === undefined) return; + if (lastWord.endsWith(" ") || lastWord.endsWith("\n")) { + this.list[this.length - 1] = lastWord.slice(0, -1); + } + } } export const words = new Words(); diff --git a/frontend/src/ts/test/words-generator.ts b/frontend/src/ts/test/words-generator.ts index 95895c4ddbfd..933d90847726 100644 --- a/frontend/src/ts/test/words-generator.ts +++ b/frontend/src/ts/test/words-generator.ts @@ -21,6 +21,7 @@ import { getActiveFunboxes, getActiveFunboxesWithFunction, isFunboxActiveWithFunction, + isFunboxActiveWithProperty, } from "./funbox/list"; import { WordGenError } from "../utils/word-gen-error"; @@ -28,6 +29,7 @@ import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; import { PolyglotWordset } from "./funbox/funbox-functions"; import { LanguageObject } from "@monkeytype/schemas/languages"; import { getCurrentQuote, isRepeated, setCurrentQuote } from "../states/test"; +import * as TestWords from "./test-words"; //pin implementation const random = Math.random; @@ -746,6 +748,13 @@ export async function getNextWord( previousWord: string, previousWord2: string | undefined, ): Promise { + // words now carry a trailing commit separator; strip it before the previous + // words feed back into dedup/punctuation/capitalization logic below. + previousWord = Strings.removeTrailingSeparator(previousWord); + if (previousWord2 !== undefined) { + previousWord2 = Strings.removeTrailingSeparator(previousWord2); + } + console.debug("Getting next word", { isRepeated: isRepeated(), currentWordset, @@ -966,6 +975,10 @@ export async function getNextWord( console.debug("Word:", randomWord); + if (!randomWord.endsWith("\n") && !isFunboxActiveWithProperty("nospace")) { + randomWord = `${randomWord} `; + } + const ret = { word: randomWord, sectionIndex: sectionIndex, @@ -975,3 +988,22 @@ export async function getNextWord( return ret; } + +export function areAllWordsGenerated(): boolean { + return ( + (Config.mode === "words" && + TestWords.words.length >= Config.words && + Config.words > 0) || + (Config.mode === "custom" && + CustomText.getLimitMode() === "word" && + TestWords.words.length >= CustomText.getLimitValue() && + CustomText.getLimitValue() !== 0) || + (Config.mode === "quote" && + TestWords.words.length >= (getCurrentQuote()?.textSplit?.length ?? 0)) || + (Config.mode === "custom" && + CustomText.getLimitMode() === "section" && + sectionIndex >= CustomText.getLimitValue() && + currentSection.length === 0 && + CustomText.getLimitValue() !== 0) + ); +} From 324a6d158fa90d34d0d80a69349bf4a93ba5392d Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Jun 2026 20:09:58 +0200 Subject: [PATCH 15/40] update command --- frontend/src/ts/commandline/lists/result-screen.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/ts/commandline/lists/result-screen.ts b/frontend/src/ts/commandline/lists/result-screen.ts index 753376e43a77..c69beaa41d81 100644 --- a/frontend/src/ts/commandline/lists/result-screen.ts +++ b/frontend/src/ts/commandline/lists/result-screen.ts @@ -7,7 +7,6 @@ import { } from "../../states/notifications"; import * as TestState from "../../test/test-state"; import * as TestWords from "../../test/test-words"; -import { removeTrailingSeparator } from "../../utils/strings"; import { Config } from "../../config/store"; import * as PractiseWords from "../../test/practise-words"; import { Command, CommandsSubgroup } from "../types"; @@ -149,10 +148,7 @@ const commands: Command[] = [ const words = Config.mode === "zen" ? inputHistory.join("") - : TestWords.words.list - .slice(0, inputHistory.length) - .map(removeTrailingSeparator) - .join(" "); + : TestWords.words.list.slice(0, inputHistory.length).join(""); navigator.clipboard.writeText(words).then( () => { From 26f4c8d9d93b509dc91d15de796beecce9f6114e Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Jun 2026 20:36:04 +0200 Subject: [PATCH 16/40] update --- .../ts/input/handlers/before-insert-text.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 156962db93a1..ad8dd47ea498 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -1,5 +1,4 @@ import { Config } from "../../config/store"; -import { getCurrentInput } from "../../test/events/data"; import * as TestState from "../../test/test-state"; import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; @@ -10,6 +9,7 @@ import { isAwaitingNextWord } from "../state"; import * as SlowTimer from "../../legacy-states/slow-timer"; import { wordsHaveNewline } from "../../states/test"; import { shouldGoToNextWord } from "../helpers/validation"; +import { isCommitCharacter } from "../helpers/util"; /** * Handles logic before inserting text into the input element. @@ -56,13 +56,20 @@ export function onBeforeInsertText(data: string): boolean { // block input if the word is too long const inputLimit = Config.mode === "zen" ? 30 : TestWords.words.getCurrentText().length + 20; - const overLimit = getCurrentInput().length >= inputLimit; + const overLimit = inputValue.length >= inputLimit; + const targetWord = TestWords.words.getCurrentText(); + const isCommit = isCommitCharacter({ + data, + inputValue, + targetWord, + }); if ( overLimit && !shouldGoToNextWord({ data, - inputValue: getCurrentInput(), - targetWord: TestWords.words.getCurrentText(), + inputValue, + targetWord, + isCommitCharacter: isCommit, }) ) { console.error("Hitting word limit"); @@ -73,8 +80,7 @@ export function onBeforeInsertText(data: string): boolean { // this will not work for the first word of each line, but that has a low chance of happening const dataIsNotFalsy = data !== null && data !== ""; const inputIsLongerThanOrEqualToWord = - getCurrentInput().length >= - removeTrailingSeparator(TestWords.words.getCurrentText()).length; + inputValue.length >= removeTrailingSeparator(targetWord).length; if ( !SlowTimer.get() && // don't do this check if slow timer is active @@ -82,7 +88,7 @@ export function onBeforeInsertText(data: string): boolean { !Config.blindMode && !Config.hideExtraLetters && inputIsLongerThanOrEqualToWord && - !dataIsSpace && + !isCommit && Config.mode !== "zen" ) { // make sure to only check this when really necessary @@ -94,7 +100,7 @@ export function onBeforeInsertText(data: string): boolean { ); const { top: topAfterAppend, height: heightAfterAppend } = TestUI.getActiveWordTopAndHeightWithDifferentData( - (pendingWordData ?? getCurrentInput()) + data, + (pendingWordData ?? inputValue) + data, ); if (topAfterAppend > TestUI.activeWordTop) { //word jumped to next line From e31babbc6674a4e1e830940062558a6b183f42e4 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Jun 2026 20:49:09 +0200 Subject: [PATCH 17/40] rename --- frontend/src/ts/elements/caret.ts | 7 +++++-- frontend/src/ts/input/handlers/before-insert-text.ts | 4 ++-- frontend/src/ts/input/helpers/word-navigation.ts | 4 ++-- frontend/src/ts/input/listeners/input.ts | 4 ++-- frontend/src/ts/test/events/stats.ts | 6 +++--- frontend/src/ts/test/pace-caret.ts | 8 +++++--- frontend/src/ts/test/practise-words.ts | 6 +++--- frontend/src/ts/test/replay-ui.ts | 4 ++-- frontend/src/ts/test/test-logic.ts | 2 +- frontend/src/ts/test/test-ui.ts | 6 +++--- frontend/src/ts/test/words-generator.ts | 4 ++-- frontend/src/ts/utils/strings.ts | 9 +++++++-- 12 files changed, 37 insertions(+), 27 deletions(-) diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index 76404494d8d8..fa705936c77d 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -1,7 +1,10 @@ import { CaretStyle } from "@monkeytype/schemas/configs"; import { Config } from "../config/store"; import { getTotalInlineMargin } from "../utils/misc"; -import { isWordRightToLeft, removeTrailingSeparator } from "../utils/strings"; +import { + isWordRightToLeft, + removeTrailingSeparatorSpace, +} from "../utils/strings"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { EasingParam, JSAnimation } from "animejs"; import { ElementWithUtils, qsr } from "../utils/dom"; @@ -288,7 +291,7 @@ export class Caret { `.word[data-wordindex="${options.wordIndex}"]`, ); - const wordText = removeTrailingSeparator( + const wordText = removeTrailingSeparatorSpace( TestWords.words.getText(options.wordIndex) ?? "", ); diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index ad8dd47ea498..0ba18b6a683f 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -3,7 +3,7 @@ import * as TestState from "../../test/test-state"; import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; import { isFunboxActiveWithProperty } from "../../test/funbox/list"; -import { isSpace, removeTrailingSeparator } from "../../utils/strings"; +import { isSpace, removeTrailingSeparatorSpace } from "../../utils/strings"; import { getInputElementValue } from "../input-element"; import { isAwaitingNextWord } from "../state"; import * as SlowTimer from "../../legacy-states/slow-timer"; @@ -80,7 +80,7 @@ export function onBeforeInsertText(data: string): boolean { // this will not work for the first word of each line, but that has a low chance of happening const dataIsNotFalsy = data !== null && data !== ""; const inputIsLongerThanOrEqualToWord = - inputValue.length >= removeTrailingSeparator(targetWord).length; + inputValue.length >= removeTrailingSeparatorSpace(targetWord).length; if ( !SlowTimer.get() && // don't do this check if slow timer is active diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index f93108ccbd6e..22fa8b227e1a 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -15,7 +15,7 @@ import { setAwaitingNextWord } from "../state"; import { DeleteInputType } from "./input-type"; import { getWordBurst } from "../../test/events/stats"; import { buildEventLog, getInputForWord } from "../../test/events/data"; -import { removeTrailingSeparator } from "../../utils/strings"; +import { removeTrailingSeparatorSpace } from "../../utils/strings"; type GoToNextWordParams = { correctInsert: boolean; @@ -105,7 +105,7 @@ export function goToPreviousWord( if (inputType === "deleteWordBackward") { setInputElementValue(""); } else if (inputType === "deleteContentBackward") { - const word = removeTrailingSeparator( + const word = removeTrailingSeparatorSpace( getInputForWord(TestState.activeWordIndex), ); if (nospaceEnabled) { diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index b14f370d93e7..17d9d1aa49a7 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -14,7 +14,7 @@ import * as CompositionState from "../../legacy-states/composition"; import * as TestState from "../../test/test-state"; import { activeWordIndex } from "../../test/test-state"; import { getCurrentInput } from "../../test/events/data"; -import { removeTrailingSeparator } from "../../utils/strings"; +import { removeTrailingSeparatorSpace } from "../../utils/strings"; import { areAllWordsGenerated } from "../../test/words-generator"; const inputEl = getInputElement(); @@ -130,7 +130,7 @@ inputEl.addEventListener("input", async (event) => { const inputPlusComposition = getCurrentInput() + (CompositionState.getData() ?? ""); const inputPlusCompositionIsCorrect = - removeTrailingSeparator(TestWords.words.getCurrentText()) === + removeTrailingSeparatorSpace(TestWords.words.getCurrentText()) === inputPlusComposition; // composition quick end diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 1106433836f4..bb0f09fb3f05 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -2,7 +2,7 @@ import { CharCounts, countChars, isSpace, - removeTrailingSeparator, + removeTrailingSeparatorSpace, } from "../../utils/strings"; import { getEventsForWord, getEventsPerWord, getInputFromDom } from "./helpers"; import { calculateWpm } from "../../utils/numbers"; @@ -364,7 +364,7 @@ function getTargetWord( // Target words store their separator as a trailing space. The last word the // user reached has no committed separator (the test ended), so strip it. - return lastWord ? removeTrailingSeparator(word) : word; + return lastWord ? removeTrailingSeparatorSpace(word) : word; } } @@ -889,7 +889,7 @@ export function getMissedWords(eventLog: EventLog): Record { ) { const word = eventLog.context.targetWords[event.data.wordIndex]; if (word === undefined) continue; - const bareWord = removeTrailingSeparator(word); + const bareWord = removeTrailingSeparatorSpace(word); missedWords[bareWord] = (missedWords[bareWord] ?? 0) + 1; } } diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 89d6b1211853..d857fb2828eb 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -1,5 +1,5 @@ import * as TestWords from "./test-words"; -import { removeTrailingSeparator } from "../utils/strings"; +import { removeTrailingSeparatorSpace } from "../utils/strings"; import { Config } from "../config/store"; import * as DB from "../db"; import { getActiveTagsPB } from "../collections/tags"; @@ -177,7 +177,8 @@ export function reset(): void { // visible word length (excludes the stored trailing separator space); throws // when the word index is out of range, which signals the pace caret is out of words function wordVisibleLength(wordIndex: number): number { - return removeTrailingSeparator(TestWords.words.getText(wordIndex)).length; + return removeTrailingSeparatorSpace(TestWords.words.getText(wordIndex)) + .length; } function incrementLetterIndex(): void { @@ -237,7 +238,8 @@ export function handleSpace(correct: boolean, currentWord: string): void { !Config.blindMode ) { settings.wordsStatus[TestState.activeWordIndex] = undefined; - settings.correction -= removeTrailingSeparator(currentWord).length + 1; + settings.correction -= + removeTrailingSeparatorSpace(currentWord).length + 1; } } else { if ( diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index bed164a1b354..626f9e24d17e 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -1,5 +1,5 @@ import * as TestWords from "./test-words"; -import { removeTrailingSeparator } from "../utils/strings"; +import { removeTrailingSeparatorSpace } from "../utils/strings"; import { showNoticeNotification } from "../states/notifications"; import { Config } from "../config/store"; @@ -64,7 +64,7 @@ export function init( let sortableMissedBiwords: [string, string, number][] = []; if (missed === "biwords") { for (let i = 0; i < TestWords.words.length; i++) { - const missedWord = removeTrailingSeparator( + const missedWord = removeTrailingSeparatorSpace( TestWords.words.getText(i) ?? "", ); const missedWordCount = missedWords[missedWord]; @@ -74,7 +74,7 @@ export function init( } else { sortableMissedBiwords.push([ missedWord, - removeTrailingSeparator(TestWords.words.getText(i - 1) ?? ""), + removeTrailingSeparatorSpace(TestWords.words.getText(i - 1) ?? ""), missedWordCount, ]); } diff --git a/frontend/src/ts/test/replay-ui.ts b/frontend/src/ts/test/replay-ui.ts index f16c58310745..4c3a6a0e8beb 100644 --- a/frontend/src/ts/test/replay-ui.ts +++ b/frontend/src/ts/test/replay-ui.ts @@ -9,7 +9,7 @@ import { getInputForWord, } from "./events/data"; import { getInputHistory, getWpmHistory } from "./events/stats"; -import { removeTrailingSeparator } from "../utils/strings"; +import { removeTrailingSeparatorSpace } from "../utils/strings"; type ReplayAction = | "correctLetter" @@ -42,7 +42,7 @@ const replayEl = qsr(".pageTest #resultReplay"); function getWordsList(): string[] { if (Config.mode === "zen") return getInputHistory(buildEventLog()); - return TestWords.words.list.map(removeTrailingSeparator); + return TestWords.words.list.map(removeTrailingSeparatorSpace); } function deriveReplayActions(): Replay[] { diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 3502aaffdb2f..e9380e5e0443 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1078,7 +1078,7 @@ export async function finish(difficultyFailed = false): Promise { if ( lastWordInputLength < - Strings.removeTrailingSeparator( + Strings.removeTrailingSeparatorSpace( TestWords.words.getText(wordIndex) ?? "", ).length ) { diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index fdd084bedf12..d5ad13ad5407 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -380,7 +380,7 @@ function buildWordHTML(word: string, wordIndex: number): string { const funbox = findSingleActiveFunboxWithFunction("getWordHtml"); // the stored trailing separator space is not rendered as a letter const chars = Strings.splitIntoCharacters( - Strings.removeTrailingSeparator(word), + Strings.removeTrailingSeparatorSpace(word), ); for (const char of chars) { if (funbox) { @@ -740,7 +740,7 @@ export async function updateWordLetters({ async () => { pendingWordData.delete(wordIndex); // strip the stored trailing separator space; it isn't rendered as a letter - const currentWord = Strings.removeTrailingSeparator( + const currentWord = Strings.removeTrailingSeparatorSpace( TestWords.words.getText(wordIndex) ?? "", ); if (!currentWord && Config.mode !== "zen") return; @@ -1339,7 +1339,7 @@ async function loadWordsHistory(): Promise { for (let i = 0; i < inputHistoryLength + 2; i++) { const input = inputHistory[i]; const corrected = correctedHistory[i]; - const word = Strings.removeTrailingSeparator( + const word = Strings.removeTrailingSeparatorSpace( TestWords.words.getText(i) ?? "", ); const koreanRegex = diff --git a/frontend/src/ts/test/words-generator.ts b/frontend/src/ts/test/words-generator.ts index 933d90847726..0bdb5550c537 100644 --- a/frontend/src/ts/test/words-generator.ts +++ b/frontend/src/ts/test/words-generator.ts @@ -750,9 +750,9 @@ export async function getNextWord( ): Promise { // words now carry a trailing commit separator; strip it before the previous // words feed back into dedup/punctuation/capitalization logic below. - previousWord = Strings.removeTrailingSeparator(previousWord); + previousWord = Strings.removeTrailingSeparatorSpace(previousWord); if (previousWord2 !== undefined) { - previousWord2 = Strings.removeTrailingSeparator(previousWord2); + previousWord2 = Strings.removeTrailingSeparatorSpace(previousWord2); } console.debug("Getting next word", { diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index 1c29caf467a5..39b02bbde718 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -360,13 +360,18 @@ export function toHex(buffer: ArrayBuffer): string { /** * Removes the trailing separator space from a target word. Target words store * the word separator as a trailing space (see test-words.ts); this strips that - * single space to get the bare/visible word. Words ending in a newline, the + * single space to get the bare/visible word. + * + * Only the separator space is removed — not a trailing newline. A trailing + * newline is actual word content: it is rendered as a letter, typed by the + * user to advance, and counted in the word's visible length, whereas the + * separator space is none of those things. Words ending in a newline, the * final word, and nospace-funbox words have no trailing space, so this is a * no-op for them. * @param word The target word. * @returns The word without its trailing separator space. */ -export function removeTrailingSeparator(word: string): string { +export function removeTrailingSeparatorSpace(word: string): string { return word.endsWith(" ") ? word.slice(0, -1) : word; } From ab1b46d8ab5aa4cb03e021a18ae6b58a84e733b3 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Jun 2026 22:49:34 +0200 Subject: [PATCH 18/40] rework --- frontend/src/ts/input/handlers/delete.ts | 4 +- frontend/src/ts/input/handlers/insert-text.ts | 8 ++-- .../src/ts/input/helpers/word-navigation.ts | 7 +--- frontend/src/ts/test/test-ui.ts | 37 ++++++++----------- 4 files changed, 24 insertions(+), 32 deletions(-) diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index 8eedfb2c1a31..7d187ce6082d 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -38,7 +38,7 @@ export function onDelete(inputType: DeleteInputType, now: number): void { }); setInputElementValue(""); - goToPreviousWord(inputType, true); + goToPreviousWord(inputType); // Record the resulting state of the previous word (newline removed) const postNavInputValue = getInputElementValue().inputValue; @@ -57,7 +57,7 @@ export function onDelete(inputType: DeleteInputType, now: number): void { if (realInputValue === "") { // 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 !== ""); + goToPreviousWord(inputType); // Record the resulting state of the destination word const postNavInputValue = getInputElementValue().inputValue; diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index d77d9f582571..f6147b22cc26 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -230,6 +230,10 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // this needs to be called after event logging WeakSpot.updateScore(data, correct); + if (lastInMultiOrSingle) { + TestUI.afterTestTextInput(correct, visualInputOverride); + } + const commitCorrect = noSpaceForce ? testInput + data === currentWord : correct; @@ -307,10 +311,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { void TestLogic.finish(); } } - - if (lastInMultiOrSingle) { - TestUI.afterTestTextInput(correct, increasedWordIndex, visualInputOverride); - } } function normalizeDataAndUpdateInputIfNeeded( diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index 22fa8b227e1a..cd1c63133e0b 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -85,16 +85,13 @@ export async function goToNextWord({ return ret; } -export function goToPreviousWord( - inputType: DeleteInputType, - forceUpdateActiveWordLetters = false, -): void { +export function goToPreviousWord(inputType: DeleteInputType): void { if (TestState.activeWordIndex === 0) { setInputElementValue(""); return; } - TestUI.beforeTestWordChange("back", null, forceUpdateActiveWordLetters); + TestUI.beforeTestWordChange("back", null); TestState.decreaseActiveWordIndex(); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index d5ad13ad5407..ba75fd3e6002 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -23,10 +23,7 @@ import { getActivePage } from "../states/core"; import Format from "../singletons/format"; import { TimerColor, TimerOpacity } from "@monkeytype/schemas/configs"; import { convertRemToPixels } from "../utils/numbers"; -import { - findSingleActiveFunboxWithFunction, - isFunboxActiveWithProperty, -} from "./funbox/list"; +import { findSingleActiveFunboxWithFunction } from "./funbox/list"; import * as TestState from "./test-state"; import * as PaceCaret from "./pace-caret"; import { @@ -734,6 +731,12 @@ export async function updateWordLetters({ input: string; compositionData: string; }): Promise { + showNoticeNotification(`Updating word letters ${wordIndex}`, { + durationMs: 250, + important: true, + }); + // console.log("updating word letters"); + console.trace(); pendingWordData.set(wordIndex, input); requestDebouncedAnimationFrame( `test-ui.updateWordLetters.${wordIndex}`, @@ -1787,7 +1790,6 @@ function afterAnyTestInput( export function afterTestTextInput( correct: boolean, - increasedWordIndex: boolean | null, inputOverride?: string, ): void { //nospace cant be handled here becauseword index @@ -1795,13 +1797,11 @@ export function afterTestTextInput( void MonkeyPower.addPower(correct); - if (!increasedWordIndex) { - void updateWordLetters({ - input: inputOverride ?? getCurrentInputForDisplay(), - wordIndex: TestState.activeWordIndex, - compositionData: CompositionState.getData(), - }); - } + void updateWordLetters({ + input: inputOverride ?? getCurrentInputForDisplay(), + wordIndex: TestState.activeWordIndex, + compositionData: CompositionState.getData(), + }); afterAnyTestInput("textInput", correct); } @@ -1828,24 +1828,19 @@ export function afterTestDelete(): void { export function beforeTestWordChange( direction: "forward", correct: boolean, - forceUpdateActiveWordLetters: boolean, + forceUpdateActiveWordLetters?: boolean, ): void; export function beforeTestWordChange( direction: "back", correct: null, - forceUpdateActiveWordLetters: boolean, + forceUpdateActiveWordLetters?: boolean, ): void; export function beforeTestWordChange( direction: "forward" | "back", correct: boolean | null, - forceUpdateActiveWordLetters: boolean, + forceUpdateActiveWordLetters?: boolean, // this param is very likely not needed ): void { - const nospaceEnabled = isFunboxActiveWithProperty("nospace"); - if ( - (Config.stopOnError === "letter" && (correct || correct === null)) || - nospaceEnabled || - forceUpdateActiveWordLetters - ) { + if (direction === "back" || forceUpdateActiveWordLetters) { void updateWordLetters({ input: getCurrentInputForDisplay(), wordIndex: TestState.activeWordIndex, From 746748983173c2454329254fca541200cfa37cb6 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 25 Jun 2026 16:20:47 +0200 Subject: [PATCH 19/40] remake --- .../src/ts/commandline/lists/result-screen.ts | 5 +- frontend/src/ts/elements/caret.ts | 2 +- .../src/ts/input/handlers/before-delete.ts | 3 +- .../ts/input/handlers/before-insert-text.ts | 9 ++- frontend/src/ts/input/handlers/delete.ts | 4 +- frontend/src/ts/input/handlers/insert-text.ts | 15 ++-- .../src/ts/input/helpers/word-navigation.ts | 11 ++- frontend/src/ts/input/listeners/input.ts | 2 +- frontend/src/ts/test/events/data.ts | 2 +- .../src/ts/test/funbox/funbox-functions.ts | 8 +- frontend/src/ts/test/pace-caret.ts | 8 +- frontend/src/ts/test/practise-words.ts | 9 ++- frontend/src/ts/test/replay-ui.ts | 4 +- frontend/src/ts/test/test-logic.ts | 27 ++----- frontend/src/ts/test/test-timer.ts | 2 +- frontend/src/ts/test/test-ui.ts | 15 ++-- frontend/src/ts/test/test-words.ts | 78 ++++++++++++------- frontend/src/ts/test/timer-progress.ts | 3 +- 18 files changed, 114 insertions(+), 93 deletions(-) diff --git a/frontend/src/ts/commandline/lists/result-screen.ts b/frontend/src/ts/commandline/lists/result-screen.ts index df7fe2a203ca..fabf754c9515 100644 --- a/frontend/src/ts/commandline/lists/result-screen.ts +++ b/frontend/src/ts/commandline/lists/result-screen.ts @@ -148,7 +148,10 @@ const commands: Command[] = [ const words = Config.mode === "zen" ? inputHistory.join("") - : TestWords.words.list.slice(0, inputHistory.length).join(" "); + : TestWords.words.list + .slice(0, inputHistory.length) + .map((word) => word.text) + .join(" "); navigator.clipboard.writeText(words).then( () => { diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index 15c326971653..8d54c3f3d4dc 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -287,7 +287,7 @@ export class Caret { const word = wordsCache.qs( `.word[data-wordindex="${options.wordIndex}"]`, ); - const wordText = TestWords.words.getText(options.wordIndex) ?? ""; + const wordText = TestWords.words.get(options.wordIndex)?.display ?? ""; const wordLength = Array.from(wordText).length; // caret can be either on the left side of the target letter or the right diff --git a/frontend/src/ts/input/handlers/before-delete.ts b/frontend/src/ts/input/handlers/before-delete.ts index b8462972ee0a..50d84df13ea0 100644 --- a/frontend/src/ts/input/handlers/before-delete.ts +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -50,9 +50,10 @@ export function onBeforeDelete(event: InputEvent): void { } const confidence = Config.confidenceMode; + const previousWord = TestWords.words.get(TestState.activeWordIndex - 1); const previousWordCorrect = getInputForWord(TestState.activeWordIndex - 1) === - TestWords.words.getText(TestState.activeWordIndex - 1); + previousWord?.textWithCommit; if (confidence === "on" && inputIsEmpty && !previousWordCorrect) { event.preventDefault(); diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 3c5f56419e6e..6e5c2fbb2dc0 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -34,7 +34,7 @@ export function onBeforeInsertText(data: string): boolean { const shouldInsertSpaceAsCharacter = shouldInsertSpaceCharacter({ data, inputValue, - targetWord: TestWords.words.getCurrentText(), + targetWord: TestWords.words.getCurrent().textWithCommit, }); //prevent space from being inserted if input is empty @@ -60,7 +60,9 @@ export function onBeforeInsertText(data: string): boolean { // block input if the word is too long const inputLimit = - Config.mode === "zen" ? 30 : TestWords.words.getCurrentText().length + 20; + Config.mode === "zen" + ? 30 + : TestWords.words.getCurrent().textWithCommit.length + 20; const overLimit = getCurrentInput().length >= inputLimit; if (overLimit && (shouldInsertSpaceAsCharacter === true || !dataIsSpace)) { console.error("Hitting word limit"); @@ -71,7 +73,8 @@ export function onBeforeInsertText(data: string): boolean { // this will not work for the first word of each line, but that has a low chance of happening const dataIsNotFalsy = data !== null && data !== ""; const inputIsLongerThanOrEqualToWord = - getCurrentInput().length >= TestWords.words.getCurrentText().length; + getCurrentInput().length >= + TestWords.words.getCurrent().textWithCommit.length; if ( !SlowTimer.get() && // don't do this check if slow timer is active diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index 8eedfb2c1a31..9ab907e12dfa 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -18,8 +18,8 @@ export function onDelete(inputType: DeleteInputType, now: number): void { const beforeDeleteOnlyTabs = /^\t*$/.test(inputBeforeDelete); const allTabsCorrect = TestWords.words - .getCurrentText() - .startsWith(inputAfterDelete); + .getCurrent() + .textWithCommit.startsWith(inputAfterDelete); //special check for code languages if ( diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index b5a9b4f13a0a..eb2256d66add 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -87,7 +87,8 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const charOverride = charOverrides.get(options.data); if ( charOverride !== undefined && - TestWords.words.getCurrentText()[getCurrentInput().length] !== options.data + TestWords.words.getCurrent().textWithCommit[getCurrentInput().length] !== + options.data ) { // replace the data with the override setInputElementValue( @@ -107,8 +108,9 @@ export async function onInsertText(options: OnInsertTextParams): Promise { for (const [targetChar, overrideChar] of languageOverrides) { if ( options.data === targetChar && - TestWords.words.getCurrentText()[getCurrentInput().length] !== - options.data + TestWords.words.getCurrent().textWithCommit[ + getCurrentInput().length + ] !== options.data ) { // replace the data with the override setInputElementValue( @@ -125,7 +127,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // input and target word const testInput = getCurrentInput(); - const currentWord = TestWords.words.getCurrentText(); + const currentWord = TestWords.words.getCurrent().textWithCommit; // if the character is visually equal, replace it with the target character // this ensures all future equivalence checks work correctly @@ -167,7 +169,8 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // word navigation check const noSpaceForce = isFunboxActiveWithProperty("nospace") && - (testInput + data).length === TestWords.words.getCurrentText().length; + (testInput + data).length === + TestWords.words.getCurrent().textWithCommit.length; // does this input try to move to the next word (before removeLastChar can block it) const goingToNextWord = ((charIsSpace || charIsNewline) && !shouldInsertSpace) || noSpaceForce; @@ -273,7 +276,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { */ //this COULD be the next word because we are awaiting goToNextWord - const nextWord = TestWords.words.getCurrentText(); + const nextWord = TestWords.words.getCurrent().textWithCommit; const doesNextWordHaveTab = /^\t+/.test(nextWord); const isCurrentCharTab = nextWord[getCurrentInput().length] === "\t"; diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index ba34c4267c7a..bbe32cb50bae 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -55,9 +55,14 @@ export async function goToNextWord({ ret.lastBurst = burst; } - PaceCaret.handleSpace(correctInsert, TestWords.words.getCurrentText()); + PaceCaret.handleSpace( + correctInsert, + TestWords.words.getCurrent().textWithCommit, + ); - const nextWord = TestWords.words.getText(TestState.activeWordIndex + 1); + const nextWord = TestWords.words.get( + TestState.activeWordIndex + 1, + )?.textWithCommit; if (nextWord !== undefined) Funbox.toggleScript(nextWord); const lastWord = TestState.activeWordIndex >= TestWords.words.length - 1; @@ -98,7 +103,7 @@ export function goToPreviousWord( TestState.decreaseActiveWordIndex(); - const word = TestWords.words.getText(TestState.activeWordIndex); + const word = TestWords.words.get(TestState.activeWordIndex)?.textWithCommit; if (word !== undefined) Funbox.toggleScript(word); const nospaceEnabled = isFunboxActiveWithProperty("nospace"); diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index c080c0dee8d2..c274ed6f6be7 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -129,7 +129,7 @@ inputEl.addEventListener("input", async (event) => { const inputPlusComposition = getCurrentInput() + (CompositionState.getData() ?? ""); const inputPlusCompositionIsCorrect = - TestWords.words.getCurrentText() === inputPlusComposition; + TestWords.words.getCurrent().textWithCommit === inputPlusComposition; // composition quick end // if the user typed the entire word correctly but is still in composition diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index 91884985bec0..c43037786bec 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -35,7 +35,7 @@ import { isFunboxActiveWithProperty } from "../funbox/active"; export function buildEventLog(): EventLog { const context = { - targetWords: [...TestWords.words.list], + targetWords: [...TestWords.words.list.map((w) => w.textWithCommit)], mode: Config.mode, mode2: getMode2(Config, getCurrentQuote()), koreanStatus: koreanStatus, diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 73d6422c6cfb..6fb43efb877c 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -54,8 +54,8 @@ async function readAheadHandleKeydown(event: KeyboardEvent): Promise { const currentInput = getCurrentInput(); const inputCurrentChar = currentInput.slice(-1); const wordCurrentChar = TestWords.words - .getCurrentText() - .slice(currentInput.length - 1, currentInput.length); + .getCurrent() + .display.slice(currentInput.length - 1, currentInput.length); const isCorrect = inputCurrentChar === wordCurrentChar; if ( @@ -63,7 +63,7 @@ async function readAheadHandleKeydown(event: KeyboardEvent): Promise { !isCorrect && (currentInput !== "" || getInputForWord(TestState.activeWordIndex - 1) !== - TestWords.words.getText(TestState.activeWordIndex - 1) || + TestWords.words.get(TestState.activeWordIndex - 1)?.display || Config.freedomMode) ) { qs("#words")?.addClass("read_ahead_disabled"); @@ -416,7 +416,7 @@ const list: Partial> = { } setTimeout(() => { highlight( - TestWords.words.getCurrentText().charAt(getCurrentInput().length), + TestWords.words.getCurrent().text.charAt(getCurrentInput().length), ); }, 1); } diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 28f962156672..f6047268d80d 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -181,7 +181,7 @@ function incrementLetterIndex(): void { if ( settings.currentLetterIndex >= // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined - TestWords.words.getText(settings.currentWordIndex)!.length + 1 + TestWords.words.get(settings.currentWordIndex)!.display.length + 1 ) { //go to the next word settings.currentLetterIndex = 0; @@ -195,8 +195,8 @@ function incrementLetterIndex(): void { //go to the previous word settings.currentLetterIndex = // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined - TestWords.words.getText(settings.currentWordIndex - 1)!.length - - 1; + TestWords.words.get(settings.currentWordIndex - 1)!.display + .length - 1; settings.currentWordIndex--; } settings.correction++; @@ -207,7 +207,7 @@ function incrementLetterIndex(): void { if ( settings.currentLetterIndex >= // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined - TestWords.words.getText(settings.currentWordIndex)!.length + TestWords.words.get(settings.currentWordIndex)!.display.length ) { //go to the next word settings.currentLetterIndex = 0; diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index 978304c28f60..ecc2a1162474 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -63,7 +63,7 @@ export function init( let sortableMissedBiwords: [string, string, number][] = []; if (missed === "biwords") { for (let i = 0; i < TestWords.words.length; i++) { - const missedWord = TestWords.words.getText(i); + const missedWord = TestWords.words.get(i)?.text; if (missedWord === undefined) continue; // won't happen, but ts complains @@ -71,7 +71,7 @@ export function init( if (missedWordCount !== undefined) { sortableMissedBiwords.push([ missedWord, - TestWords.words.getText(i - 1) ?? "", + TestWords.words.get(i - 1)?.text ?? "", missedWordCount, ]); } @@ -94,8 +94,9 @@ export function init( let sortableSlowWords: [string, number][] = []; if (slow) { const typedWords = TestWords.words - .getText() - .slice(0, getInputHistory(lastEventLog).length - 1); + .get() + .slice(0, getInputHistory(lastEventLog).length - 1) + .map((word) => word.text); const burstHistory = getWordBurstHistory(lastEventLog); diff --git a/frontend/src/ts/test/replay-ui.ts b/frontend/src/ts/test/replay-ui.ts index ac0e43ada6f6..bd8143a49898 100644 --- a/frontend/src/ts/test/replay-ui.ts +++ b/frontend/src/ts/test/replay-ui.ts @@ -41,7 +41,7 @@ const replayEl = qsr(".pageTest #resultReplay"); function getWordsList(): string[] { if (Config.mode === "zen") return getInputHistory(buildEventLog()); - return TestWords.words.list.slice(); + return TestWords.words.list.slice().map((word) => word.text); } function deriveReplayActions(): Replay[] { @@ -59,7 +59,7 @@ function deriveReplayActions(): Replay[] { const target = Config.mode === "zen" ? typed - : TestWords.words.getText(prevWordIndex); + : TestWords.words.get(prevWordIndex)?.text; const correct = typed === target; actions.push({ action: correct ? "submitCorrectWord" : "submitErrorWord", diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index e5b47d0eb492..0f28a0692a18 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -2,7 +2,6 @@ import Ape from "../ape"; import * as TestUI from "./test-ui"; import * as Strings from "../utils/strings"; import * as Misc from "../utils/misc"; -import * as Arrays from "../utils/arrays"; import * as JSONData from "../utils/json-data"; import * as Numbers from "@monkeytype/util/numbers"; import { @@ -610,17 +609,10 @@ async function init(): Promise { } if (Config.keymapMode === "next" && Config.mode !== "zen") { - highlight( - Arrays.nthElementFromArray( - // ignoring for now but this might need a different approach - // oxlint-disable-next-line no-misused-spread - [...TestWords.words.getCurrentText()], - 0, - ) as string, - ); + highlight(TestWords.words.getCurrent()?.text[0] ?? ""); } - Funbox.toggleScript(TestWords.words.getCurrentText()); + Funbox.toggleScript(TestWords.words.getCurrent().text); TestUI.setJoiningClass(allJoiningScript ?? language.joiningScript ?? false); const isLanguageRTL = allRightToLeft ?? language.rightToLeft ?? false; @@ -716,8 +708,8 @@ export async function addWord(): Promise { const randomWord = await WordsGenerator.getNextWord( TestWords.words.length, bound, - TestWords.words.getText(TestWords.words.length - 1), - TestWords.words.getText(TestWords.words.length - 2), + TestWords.words.get(TestWords.words.length - 1)?.text ?? "", + TestWords.words.get(TestWords.words.length - 2)?.text, ); TestWords.words.push(randomWord.word, randomWord.sectionIndex); @@ -1080,7 +1072,7 @@ export async function finish(difficultyFailed = false): Promise { const lastWordInputLength = history[wordIndex]?.length ?? 0; if ( - lastWordInputLength < (TestWords.words.getText(wordIndex)?.length ?? 0) + lastWordInputLength < (TestWords.words.get(wordIndex)?.text.length ?? 0) ) { historyLength--; } @@ -1420,14 +1412,7 @@ configEvent.subscribe(({ key, newValue, nosave }) => { if (key === "keymapMode" && newValue === "next" && Config.mode !== "zen") { setTimeout(() => { - highlight( - Arrays.nthElementFromArray( - // ignoring for now but this might need a different approach - // oxlint-disable-next-line no-misused-spread - [...TestWords.words.getCurrentText()], - 0, - ) as string, - ); + highlight(TestWords.words.getCurrent()?.text[0] ?? ""); }, 0); } if ( diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 6a1b45dda7a4..f890cefcabee 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -188,7 +188,7 @@ function layoutfluid(): void { if (Config.keymapMode === "next") { setTimeout(() => { highlight( - TestWords.words.getCurrentText().charAt(getCurrentInput().length), + TestWords.words.getCurrent().text.charAt(getCurrentInput().length), ); }, 1); } diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 89f919421701..fafc54f80f5e 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -228,7 +228,7 @@ async function joinOverlappingHints( hintElements: HTMLCollection, ): Promise { const [isWordRightToLeft] = Strings.isWordRightToLeft( - TestWords.words.getCurrentText(), + TestWords.words.getCurrent().text, TestState.isLanguageRightToLeft, TestState.isDirectionReversed, ); @@ -501,9 +501,9 @@ function showWords(): void { } else { let wordsHTML = ""; for (let i = 0; i < TestWords.words.length; i++) { - const word = TestWords.words.getText(i); + const word = TestWords.words.get(i); if (word === undefined) continue; // won't happen, but ts complains - wordsHTML += buildWordHTML(word, i); + wordsHTML += buildWordHTML(word.display, i); } wordsEl.setHtml(wordsHTML); } @@ -738,7 +738,7 @@ export async function updateWordLetters({ `test-ui.updateWordLetters.${wordIndex}`, async () => { pendingWordData.delete(wordIndex); - const currentWord = TestWords.words.getText(wordIndex); + const currentWord = TestWords.words.get(wordIndex)?.display; if (currentWord === undefined && Config.mode !== "zen") return; let ret = ""; const wordAtIndex = getWordElement(wordIndex); @@ -1335,7 +1335,7 @@ async function loadWordsHistory(): Promise { for (let i = 0; i < inputHistoryLength + 2; i++) { const input = inputHistory[i]; const corrected = correctedHistory[i]; - const word = TestWords.words.getText(i) ?? ""; + const word = TestWords.words.get(i)?.text ?? ""; const koreanRegex = /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/; const containsKorean = @@ -1770,7 +1770,7 @@ function afterAnyTestInput( if (Config.keymapMode === "next") { highlight( - TestWords.words.getCurrentText().charAt(getCurrentInput().length), + TestWords.words.getCurrent().text.charAt(getCurrentInput().length), ); } @@ -1966,8 +1966,9 @@ qs(".pageTest #copyWordsListButton")?.on("click", async () => { words = getInputHistory(TestState.lastEventLog).join(""); } else { words = TestWords.words - .getText() + .get() .slice(0, getInputHistory(TestState.lastEventLog).length) + .map((w) => w.text) .join(" "); } await copyToClipboard(words); diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index 63b30a92b7f1..59f969ad8823 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -1,57 +1,77 @@ import * as TestState from "./test-state"; +type CommitChar = " " | "\n" | ""; + +type Word = { + text: string; + textWithCommit: string; + commit: CommitChar; + display: string; + sectionIndex: number; +}; + +const commitCharsToDisplay: CommitChar[] = ["\n"]; + class Words { - public list: string[]; - public sectionIndexList: number[]; + public list: Word[]; public length: number; constructor() { this.list = []; - this.sectionIndexList = []; this.length = 0; } - getText(i?: undefined, raw?: boolean): string[]; - getText(i: number, raw?: boolean): string | undefined; - getText(i?: number, raw = false): string | string[] | undefined { + get(i?: undefined, raw?: boolean): Word[]; + get(i: number, raw?: boolean): Word | undefined; + get(i?: number, raw = false): Word | Word[] | undefined { if (i === undefined) { return this.list; } else { + const word = this.list[i]; + if (!word) { + return undefined; + } if (raw) { - return this.list[i]?.replace(/[.?!":\-,]/g, "")?.toLowerCase(); + const text = word.text.replace(/[.?!":\-,]/g, "")?.toLowerCase(); + return { + text, + textWithCommit: text + word.commit, + display: + text + + (commitCharsToDisplay.includes(word.commit) ? word.commit : ""), + commit: word.commit, + sectionIndex: word.sectionIndex, + }; } else { - return this.list[i]; + return word; } } } - getCurrentText(): string { - return this.list[TestState.activeWordIndex] ?? ""; - } - getLast(): string { - return this.list[this.list.length - 1] as string; + getCurrent(): Word { + return this.list[TestState.activeWordIndex] as Word; } push(word: string, sectionIndex: number): void { - this.list.push(word); - this.sectionIndexList.push(sectionIndex); + let commit: CommitChar = ""; + if (word.endsWith(" ")) { + commit = " "; + word = word.slice(0, -1); + } else if (word.endsWith("\n")) { + commit = "\n"; + word = word.slice(0, -1); + } + this.list.push({ + text: word, + textWithCommit: word + commit, + commit, + display: word + (commitCharsToDisplay.includes(commit) ? commit : ""), + sectionIndex, + }); this.length = this.list.length; } reset(): void { this.list = []; - this.sectionIndexList = []; - this.length = this.list.length; - } - clean(): void { - for (const s of this.list) { - if (/ +/.test(s)) { - const id = this.list.indexOf(s); - const tempList = s.split(" "); - this.list.splice(id, 1); - for (let i = 0; i < tempList.length; i++) { - this.list.splice(id + i, 0, tempList[i] as string); - } - } - } + this.length = 0; } } diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index ac19c3b6a198..6889089c654f 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -107,8 +107,7 @@ export function instantHide(): void { function getCurrentCount(): number { if (Config.mode === "custom" && CustomText.getLimitMode() === "section") { return ( - (TestWords.words.sectionIndexList[TestState.activeWordIndex] as number) - - 1 + (TestWords.words.get(TestState.activeWordIndex)?.sectionIndex ?? 0) - 1 ); } else { return TestState.activeWordIndex; From bc27c48afb71adc39990f0113358857aaa958fb9 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 25 Jun 2026 16:27:31 +0200 Subject: [PATCH 20/40] fix tests --- frontend/__tests__/test/events/stats.spec.ts | 57 ++++++++++++-------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 5569188a25de..fcaa3bc1c653 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -17,16 +17,25 @@ vi.mock("../../../src/ts/config/store", () => ({ })); vi.mock("../../../src/ts/test/test-words", () => { - const list: string[] = []; + type CommitChar = " " | "\n" | ""; + type Word = { text: string; textWithCommit: string; commit: CommitChar }; + const list: Word[] = []; return { words: { list, - getText(i?: number) { - if (i === undefined) return list; - return list[i]; + push(word: string) { + let commit: CommitChar = ""; + if (word.endsWith(" ")) { + commit = " "; + word = word.slice(0, -1); + } else if (word.endsWith("\n")) { + commit = "\n"; + word = word.slice(0, -1); + } + list.push({ text: word, textWithCommit: word + commit, commit }); }, - getCurrentText() { - return list[list.length - 1] ?? ""; + reset() { + list.length = 0; }, }, }; @@ -79,6 +88,10 @@ import { Keycode } from "../../../src/ts/constants/keys"; import * as TestState from "../../../src/ts/test/test-state"; import { words as TestWords } from "../../../src/ts/test/test-words"; +function pushWords(...words: string[]): void { + words.forEach((word, i) => TestWords.push(word, i)); +} + function keyDown(code: Keycode = "KeyA"): KeydownEventData { return { code }; } @@ -170,7 +183,7 @@ describe("stats.ts", () => { (Config as { words: number }).words = 25; (Config as { time: number }).time = 0; (TestState as { activeWordIndex: number }).activeWordIndex = 0; - TestWords.list.length = 0; + TestWords.reset(); inputPerWord.clear(); }); @@ -695,7 +708,7 @@ describe("stats.ts", () => { // 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"); + pushWords("hello", "leave"); logTestEvent("timer", 1000, timer("start", 0)); logTestEvent( @@ -1014,28 +1027,28 @@ describe("stats.ts", () => { }); it("returns word without trailing space when it ends with newline", () => { - TestWords.list.push("hello\n"); + pushWords("hello\n"); expect( statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), ).toBe("hello\n"); }); it("appends trailing space for non-last word", () => { - TestWords.list.push("hello"); + pushWords("hello"); expect( statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), ).toBe("hello "); }); it("does not append trailing space for last word", () => { - TestWords.list.push("hello"); + pushWords("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"); + pushWords("hello"); (Config as { funbox: string[] }).funbox = ["nospace"]; expect( statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), @@ -1043,7 +1056,7 @@ describe("stats.ts", () => { }); it("does not append trailing space when underscore_spaces funbox is active", () => { - TestWords.list.push("hello"); + pushWords("hello"); (Config as { funbox: string[] }).funbox = ["underscore_spaces"]; expect( statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), @@ -1053,7 +1066,7 @@ describe("stats.ts", () => { describe("getChars", () => { it("counts all correct for a perfectly typed word", () => { - TestWords.list.push("hello"); + pushWords("hello"); (TestState as { activeWordIndex: number }).activeWordIndex = 0; logTestEvent("timer", 1000, timer("start", 0)); @@ -1074,7 +1087,7 @@ describe("stats.ts", () => { }); it("counts incorrect chars", () => { - TestWords.list.push("ab"); + pushWords("ab"); (TestState as { activeWordIndex: number }).activeWordIndex = 0; logTestEvent("timer", 1000, timer("start", 0)); @@ -1095,7 +1108,7 @@ describe("stats.ts", () => { }); it("counts extra chars", () => { - TestWords.list.push("ab"); + pushWords("ab"); (TestState as { activeWordIndex: number }).activeWordIndex = 0; logTestEvent("timer", 1000, timer("start", 0)); @@ -1120,7 +1133,7 @@ describe("stats.ts", () => { }); it("counts missed chars for completed non-last words", () => { - TestWords.list.push("hello", "world"); + pushWords("hello", "world"); (TestState as { activeWordIndex: number }).activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); @@ -1166,7 +1179,7 @@ describe("stats.ts", () => { it("credits a word committed with an IME full-width space", () => { // Japanese IME commits words with the ideographic space U+3000, while the // target word separator is a regular space — normalize so it still counts - TestWords.list.push("しり", "かこ"); + pushWords("しり", "かこ"); (TestState as { activeWordIndex: number }).activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); @@ -1206,7 +1219,7 @@ describe("stats.ts", () => { describe("getWpmHistory", () => { it("returns wpm at each timer boundary", () => { - TestWords.list.push("hello"); + pushWords("hello"); (TestState as { activeWordIndex: number }).activeWordIndex = 0; logTestEvent("timer", 1000, timer("start", 0)); @@ -1227,7 +1240,7 @@ describe("stats.ts", () => { }); it("returns cumulative wpm across boundaries", () => { - TestWords.list.push("ab", "cd"); + pushWords("ab", "cd"); (TestState as { activeWordIndex: number }).activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); @@ -1272,7 +1285,7 @@ describe("stats.ts", () => { it("counts non-last word as correct without trailing space when nospace funbox is active", () => { (Config as { funbox: string[] }).funbox = ["nospace"]; - TestWords.list.push("ab", "cd"); + pushWords("ab", "cd"); (TestState as { activeWordIndex: number }).activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); @@ -1306,7 +1319,7 @@ describe("stats.ts", () => { }); it("counts multiline word as correct when target ends in newline", () => { - TestWords.list.push("hello\n", "world"); + pushWords("hello\n", "world"); (TestState as { activeWordIndex: number }).activeWordIndex = 1; logTestEvent("timer", 1000, timer("start", 0)); From a016f7a6850130c6d90773276266b59232c275a3 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 25 Jun 2026 18:23:28 +0200 Subject: [PATCH 21/40] better type --- .../src/ts/input/handlers/before-insert-text.ts | 11 +++++------ frontend/src/ts/input/handlers/delete.ts | 2 +- frontend/src/ts/input/handlers/insert-text.ts | 10 +++++----- .../src/ts/input/helpers/word-navigation.ts | 2 +- frontend/src/ts/input/listeners/input.ts | 2 +- frontend/src/ts/test/funbox/funbox-functions.ts | 17 +++++++++++++---- frontend/src/ts/test/test-logic.ts | 2 +- frontend/src/ts/test/test-timer.ts | 4 +++- frontend/src/ts/test/test-ui.ts | 7 +++++-- frontend/src/ts/test/test-words.ts | 4 ++-- 10 files changed, 37 insertions(+), 24 deletions(-) diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 6e5c2fbb2dc0..63bd9be0beb6 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -30,11 +30,13 @@ export function onBeforeInsertText(data: string): boolean { } const { inputValue } = getInputElementValue(); + const currentWordTextWithCommit = + TestWords.words.getCurrent()?.textWithCommit ?? ""; const dataIsSpace = isSpace(data); const shouldInsertSpaceAsCharacter = shouldInsertSpaceCharacter({ data, inputValue, - targetWord: TestWords.words.getCurrent().textWithCommit, + targetWord: currentWordTextWithCommit, }); //prevent space from being inserted if input is empty @@ -60,9 +62,7 @@ export function onBeforeInsertText(data: string): boolean { // block input if the word is too long const inputLimit = - Config.mode === "zen" - ? 30 - : TestWords.words.getCurrent().textWithCommit.length + 20; + Config.mode === "zen" ? 30 : currentWordTextWithCommit.length + 20; const overLimit = getCurrentInput().length >= inputLimit; if (overLimit && (shouldInsertSpaceAsCharacter === true || !dataIsSpace)) { console.error("Hitting word limit"); @@ -73,8 +73,7 @@ export function onBeforeInsertText(data: string): boolean { // this will not work for the first word of each line, but that has a low chance of happening const dataIsNotFalsy = data !== null && data !== ""; const inputIsLongerThanOrEqualToWord = - getCurrentInput().length >= - TestWords.words.getCurrent().textWithCommit.length; + getCurrentInput().length >= currentWordTextWithCommit.length; if ( !SlowTimer.get() && // don't do this check if slow timer is active diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index 9ab907e12dfa..14717b4daa58 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -19,7 +19,7 @@ export function onDelete(inputType: DeleteInputType, now: number): void { const beforeDeleteOnlyTabs = /^\t*$/.test(inputBeforeDelete); const allTabsCorrect = TestWords.words .getCurrent() - .textWithCommit.startsWith(inputAfterDelete); + ?.textWithCommit.startsWith(inputAfterDelete); //special check for code languages if ( diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index eb2256d66add..03289668628b 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -87,7 +87,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const charOverride = charOverrides.get(options.data); if ( charOverride !== undefined && - TestWords.words.getCurrent().textWithCommit[getCurrentInput().length] !== + TestWords.words.getCurrent()?.textWithCommit[getCurrentInput().length] !== options.data ) { // replace the data with the override @@ -108,7 +108,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { for (const [targetChar, overrideChar] of languageOverrides) { if ( options.data === targetChar && - TestWords.words.getCurrent().textWithCommit[ + TestWords.words.getCurrent()?.textWithCommit[ getCurrentInput().length ] !== options.data ) { @@ -127,7 +127,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // input and target word const testInput = getCurrentInput(); - const currentWord = TestWords.words.getCurrent().textWithCommit; + const currentWord = TestWords.words.getCurrent()?.textWithCommit ?? ""; // if the character is visually equal, replace it with the target character // this ensures all future equivalence checks work correctly @@ -170,7 +170,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const noSpaceForce = isFunboxActiveWithProperty("nospace") && (testInput + data).length === - TestWords.words.getCurrent().textWithCommit.length; + TestWords.words.getCurrent()?.textWithCommit.length; // does this input try to move to the next word (before removeLastChar can block it) const goingToNextWord = ((charIsSpace || charIsNewline) && !shouldInsertSpace) || noSpaceForce; @@ -276,7 +276,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { */ //this COULD be the next word because we are awaiting goToNextWord - const nextWord = TestWords.words.getCurrent().textWithCommit; + const nextWord = TestWords.words.getCurrent()?.textWithCommit ?? ""; const doesNextWordHaveTab = /^\t+/.test(nextWord); const isCurrentCharTab = nextWord[getCurrentInput().length] === "\t"; diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index bbe32cb50bae..92a1ff51dc2f 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -57,7 +57,7 @@ export async function goToNextWord({ PaceCaret.handleSpace( correctInsert, - TestWords.words.getCurrent().textWithCommit, + TestWords.words.getCurrent()?.textWithCommit ?? "", ); const nextWord = TestWords.words.get( diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index c274ed6f6be7..02948c590545 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -129,7 +129,7 @@ inputEl.addEventListener("input", async (event) => { const inputPlusComposition = getCurrentInput() + (CompositionState.getData() ?? ""); const inputPlusCompositionIsCorrect = - TestWords.words.getCurrent().textWithCommit === inputPlusComposition; + TestWords.words.getCurrent()?.textWithCommit === inputPlusComposition; // composition quick end // if the user typed the entire word correctly but is still in composition diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 6fb43efb877c..0824ddf34f2e 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -52,10 +52,17 @@ export type FunboxFunctions = { async function readAheadHandleKeydown(event: KeyboardEvent): Promise { const currentInput = getCurrentInput(); + const currentWord = TestWords.words.getCurrent(); + + if (!currentWord) { + return; + } + const inputCurrentChar = currentInput.slice(-1); - const wordCurrentChar = TestWords.words - .getCurrent() - .display.slice(currentInput.length - 1, currentInput.length); + const wordCurrentChar = currentWord.display.slice( + currentInput.length - 1, + currentInput.length, + ); const isCorrect = inputCurrentChar === wordCurrentChar; if ( @@ -416,7 +423,9 @@ const list: Partial> = { } setTimeout(() => { highlight( - TestWords.words.getCurrent().text.charAt(getCurrentInput().length), + TestWords.words + .getCurrent() + ?.text.charAt(getCurrentInput().length) ?? "", ); }, 1); } diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 0f28a0692a18..79e7192db7a9 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -612,7 +612,7 @@ async function init(): Promise { highlight(TestWords.words.getCurrent()?.text[0] ?? ""); } - Funbox.toggleScript(TestWords.words.getCurrent().text); + Funbox.toggleScript(TestWords.words.getCurrent()?.text ?? ""); TestUI.setJoiningClass(allJoiningScript ?? language.joiningScript ?? false); const isLanguageRTL = allRightToLeft ?? language.rightToLeft ?? false; diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index f890cefcabee..022f3673bd3f 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -188,7 +188,9 @@ function layoutfluid(): void { if (Config.keymapMode === "next") { setTimeout(() => { highlight( - TestWords.words.getCurrent().text.charAt(getCurrentInput().length), + TestWords.words + .getCurrent() + ?.text.charAt(getCurrentInput().length) ?? "", ); }, 1); } diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index fafc54f80f5e..cbfed884ea3f 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -227,8 +227,11 @@ async function joinOverlappingHints( activeWordLetters: ElementsWithUtils, hintElements: HTMLCollection, ): Promise { + const currentWord = TestWords.words.getCurrent(); + if (currentWord === undefined) return; + const [isWordRightToLeft] = Strings.isWordRightToLeft( - TestWords.words.getCurrent().text, + currentWord.text, TestState.isLanguageRightToLeft, TestState.isDirectionReversed, ); @@ -1770,7 +1773,7 @@ function afterAnyTestInput( if (Config.keymapMode === "next") { highlight( - TestWords.words.getCurrent().text.charAt(getCurrentInput().length), + TestWords.words.getCurrent()?.text.charAt(getCurrentInput().length) ?? "", ); } diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index 59f969ad8823..0527c45e3dc8 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -47,8 +47,8 @@ class Words { } } } - getCurrent(): Word { - return this.list[TestState.activeWordIndex] as Word; + getCurrent(): Word | undefined { + return this.list[TestState.activeWordIndex]; } push(word: string, sectionIndex: number): void { let commit: CommitChar = ""; From f4a26759b81fb40f19f261075941b68f54af8d3f Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 25 Jun 2026 18:31:56 +0200 Subject: [PATCH 22/40] use text --- frontend/src/ts/input/helpers/word-navigation.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index 92a1ff51dc2f..cad34a02e7ab 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -60,9 +60,7 @@ export async function goToNextWord({ TestWords.words.getCurrent()?.textWithCommit ?? "", ); - const nextWord = TestWords.words.get( - TestState.activeWordIndex + 1, - )?.textWithCommit; + const nextWord = TestWords.words.get(TestState.activeWordIndex + 1)?.text; if (nextWord !== undefined) Funbox.toggleScript(nextWord); const lastWord = TestState.activeWordIndex >= TestWords.words.length - 1; @@ -103,7 +101,7 @@ export function goToPreviousWord( TestState.decreaseActiveWordIndex(); - const word = TestWords.words.get(TestState.activeWordIndex)?.textWithCommit; + const word = TestWords.words.get(TestState.activeWordIndex)?.text; if (word !== undefined) Funbox.toggleScript(word); const nospaceEnabled = isFunboxActiveWithProperty("nospace"); From adc7548a42e8082e34f4c8cbbad660e1b848fc9d Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 25 Jun 2026 18:48:53 +0200 Subject: [PATCH 23/40] def 1 --- frontend/src/ts/test/timer-progress.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index 6889089c654f..41ed386839ba 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -107,7 +107,7 @@ export function instantHide(): void { function getCurrentCount(): number { if (Config.mode === "custom" && CustomText.getLimitMode() === "section") { return ( - (TestWords.words.get(TestState.activeWordIndex)?.sectionIndex ?? 0) - 1 + (TestWords.words.get(TestState.activeWordIndex)?.sectionIndex ?? 1) - 1 ); } else { return TestState.activeWordIndex; From 532e6b10b6f8733880a26c15ce7da924a3b0c089 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 25 Jun 2026 19:00:27 +0200 Subject: [PATCH 24/40] guard --- frontend/src/ts/test/timer-progress.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index 41ed386839ba..4e8063cd0752 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -106,9 +106,14 @@ export function instantHide(): void { function getCurrentCount(): number { if (Config.mode === "custom" && CustomText.getLimitMode() === "section") { - return ( - (TestWords.words.get(TestState.activeWordIndex)?.sectionIndex ?? 1) - 1 - ); + const currentSectionIndex = TestWords.words.get( + TestState.activeWordIndex, + )?.sectionIndex; + + if (currentSectionIndex === undefined) { + return 0; + } + return currentSectionIndex - 1; } else { return TestState.activeWordIndex; } From 7a32bcded634f6d85636a4263dcd5f7abdf46b37 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 19:02:49 +0200 Subject: [PATCH 25/40] brrr --- frontend/__tests__/test/events/stats.spec.ts | 50 ++----------------- frontend/__tests__/test/test-words.spec.ts | 16 +++--- .../src/ts/commandline/lists/result-screen.ts | 3 +- .../src/ts/input/helpers/word-navigation.ts | 7 +-- frontend/src/ts/test/events/helpers.ts | 2 +- frontend/src/ts/test/events/stats.ts | 29 ++--------- frontend/src/ts/test/pace-caret.ts | 20 ++++---- frontend/src/ts/test/test-ui.ts | 4 +- frontend/src/ts/utils/strings.ts | 18 ------- 9 files changed, 32 insertions(+), 117 deletions(-) diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index b80226bebe06..3d8c41a3d034 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -1022,54 +1022,12 @@ describe("stats.ts", () => { }); describe("getTargetWord", () => { - it("returns simulatedInput in zen mode", () => { - (Config as { mode: string }).mode = "zen"; - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "anything", false), - ).toBe("anything"); - }); - - it("returns word without trailing space when it ends with newline", () => { - pushWords("hello\n"); - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), - ).toBe("hello\n"); - }); - - it("appends trailing space for non-last word", () => { - pushWords("hello"); - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), - ).toBe("hello "); - }); - - it("does not append trailing space for last word", () => { + it("returns word", () => { pushWords("hello"); - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", true), - ).toBe("hello"); + expect(statsTesting.getTargetWord(buildEventLog(), 0)).toBe("hello"); }); - - it("does not append trailing space when nospace funbox is active", () => { - pushWords("hello"); - (Config as { funbox: string[] }).funbox = ["nospace"]; - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), - ).toBe("hello\n"); - }); - - it("does not append trailing space when underscore_spaces funbox is active", () => { - pushWords("hello"); - (Config as { funbox: string[] }).funbox = ["underscore_spaces"]; - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", true), - ).toBe("hello"); - }); - - it("returns empty string for an out-of-range word index", () => { - expect( - statsTesting.getTargetWord(buildEventLog(), 5, "hello", false), - ).toBe(""); + it("returns for out-of-range", () => { + expect(statsTesting.getTargetWord(buildEventLog(), 0)).toBe(undefined); }); }); diff --git a/frontend/__tests__/test/test-words.spec.ts b/frontend/__tests__/test/test-words.spec.ts index 84a4612de697..8c9a759f69d4 100644 --- a/frontend/__tests__/test/test-words.spec.ts +++ b/frontend/__tests__/test/test-words.spec.ts @@ -18,9 +18,9 @@ describe("test-words", () => { words.push("the ", 0); words.push("cat ", 0); words.push("sat", 0); - expect(words.list.map((w) => w.text)).toEqual(["the", "cat", "sat"]); - expect(words.list.map((w) => w.commit)).toEqual([" ", " ", ""]); - expect(words.list.map((w) => w.textWithCommit)).toEqual([ + expect(words.get().map((w) => w.text)).toEqual(["the", "cat", "sat"]); + expect(words.get().map((w) => w.commit)).toEqual([" ", " ", ""]); + expect(words.get().map((w) => w.textWithCommit)).toEqual([ "the ", "cat ", "sat", @@ -31,7 +31,7 @@ describe("test-words", () => { words.push("a ", 3); words.push("b", 5); expect(words.length).toBe(2); - expect(words.list.map((w) => w.sectionIndex)).toEqual([3, 5]); + expect(words.get().map((w) => w.sectionIndex)).toEqual([3, 5]); }); }); @@ -40,25 +40,25 @@ describe("test-words", () => { words.push("the ", 0); words.push("end ", 0); words.removeCommitCharacterFromLastWord(); - expect(words.list.map((w) => w.textWithCommit)).toEqual(["the ", "end"]); + expect(words.get().map((w) => w.textWithCommit)).toEqual(["the ", "end"]); }); it("strips a trailing newline from the last word", () => { words.push("line\n", 0); words.removeCommitCharacterFromLastWord(); - expect(words.list.map((w) => w.textWithCommit)).toEqual(["line"]); + expect(words.get().map((w) => w.textWithCommit)).toEqual(["line"]); }); it("leaves a bare last word unchanged", () => { words.push("the ", 0); words.push("end", 0); words.removeCommitCharacterFromLastWord(); - expect(words.list.map((w) => w.textWithCommit)).toEqual(["the ", "end"]); + expect(words.get().map((w) => w.textWithCommit)).toEqual(["the ", "end"]); }); it("does nothing on an empty list", () => { expect(() => words.removeCommitCharacterFromLastWord()).not.toThrow(); - expect(words.list).toEqual([]); + expect(words.get()).toEqual([]); }); }); }); diff --git a/frontend/src/ts/commandline/lists/result-screen.ts b/frontend/src/ts/commandline/lists/result-screen.ts index 78a5052e06a0..d629b69ffaf0 100644 --- a/frontend/src/ts/commandline/lists/result-screen.ts +++ b/frontend/src/ts/commandline/lists/result-screen.ts @@ -148,7 +148,8 @@ const commands: Command[] = [ const words = Config.mode === "zen" ? inputHistory.join("") - : TestWords.words.list + : TestWords.words + .get() .slice(0, inputHistory.length) .map((word) => word.textWithCommit) .join(""); diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index 759312a3db61..ec24be01e7f4 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -15,7 +15,6 @@ import { setAwaitingNextWord } from "../state"; import { DeleteInputType } from "./input-type"; import { getWordBurst } from "../../test/events/stats"; import { buildEventLog, getInputForWord } from "../../test/events/data"; -import { removeTrailingSeparatorSpace } from "../../utils/strings"; type GoToNextWordParams = { correctInsert: boolean; @@ -107,12 +106,10 @@ export function goToPreviousWord(inputType: DeleteInputType): void { if (inputType === "deleteWordBackward") { setInputElementValue(""); } else if (inputType === "deleteContentBackward") { - const word = removeTrailingSeparatorSpace( - getInputForWord(TestState.activeWordIndex), - ); + const word = getInputForWord(TestState.activeWordIndex); if (nospaceEnabled) { setInputElementValue(word.slice(0, -1)); - } else if (word.endsWith("\n")) { + } else if (word.endsWith("\n") || word.endsWith(" ")) { setInputElementValue(word.slice(0, -1)); } else { setInputElementValue(word); diff --git a/frontend/src/ts/test/events/helpers.ts b/frontend/src/ts/test/events/helpers.ts index d236f88a6101..779559c3d366 100644 --- a/frontend/src/ts/test/events/helpers.ts +++ b/frontend/src/ts/test/events/helpers.ts @@ -143,7 +143,7 @@ export function getInputFromDom(events: TestEventNoMs[]): string { !data.correct ) { // if this is an incorrect word commit on the last word, we dont want to count it at all - return inputValue.trimEnd(); + return inputValue; } return inputValue; diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index bb0f09fb3f05..041203ca0d01 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -1,9 +1,4 @@ -import { - CharCounts, - countChars, - isSpace, - removeTrailingSeparatorSpace, -} from "../../utils/strings"; +import { CharCounts, countChars, isSpace } from "../../utils/strings"; import { getEventsForWord, getEventsPerWord, getInputFromDom } from "./helpers"; import { calculateWpm } from "../../utils/numbers"; import { roundTo2 } from "@monkeytype/util/numbers"; @@ -350,22 +345,8 @@ export function getDateBasedTestDurationMs(eventLog: EventLog): number { function getTargetWord( eventLog: EventLog, wordIndex: number, - simulatedInput: string, - lastWord: boolean, -): string { - if (eventLog.context.mode === "zen") { - return simulatedInput; - } else { - const word = eventLog.context.targetWords[wordIndex]; - - if (word === undefined) { - return ""; - } - - // Target words store their separator as a trailing space. The last word the - // user reached has no committed separator (the test ended), so strip it. - return lastWord ? removeTrailingSeparatorSpace(word) : word; - } +): string | undefined { + return eventLog.context.targetWords[wordIndex]; } function computeBurst(events: TestEventNoMs[], now?: number): number { @@ -455,7 +436,7 @@ function countCharsForWordIndex( simulatedInput = Hangul.disassemble(simulatedInput).join(""); } - let targetWord = getTargetWord(eventLog, wordIndex, simulatedInput, lastWord); + let targetWord = getTargetWord(eventLog, wordIndex) ?? simulatedInput; if (eventLog.context.koreanStatus) { targetWord = Hangul.disassemble(targetWord).join(""); } @@ -889,7 +870,7 @@ export function getMissedWords(eventLog: EventLog): Record { ) { const word = eventLog.context.targetWords[event.data.wordIndex]; if (word === undefined) continue; - const bareWord = removeTrailingSeparatorSpace(word); + const bareWord = word; missedWords[bareWord] = (missedWords[bareWord] ?? 0) + 1; } } diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index a1781fd8ae8d..31a950a29920 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -1,5 +1,4 @@ import * as TestWords from "./test-words"; -import { removeTrailingSeparatorSpace } from "../utils/strings"; import { Config } from "../config/store"; import * as DB from "../db"; import { getActiveTagsPB } from "../collections/tags"; @@ -178,26 +177,26 @@ function incrementLetterIndex(): void { if (settings === null) return; try { - settings.currentLetterIndex++; if ( settings.currentLetterIndex >= // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined - TestWords.words.get(settings.currentWordIndex)!.display.length + 1 + TestWords.words.get(settings.currentWordIndex)!.text.length ) { //go to the next word - settings.currentLetterIndex = 0; + settings.currentLetterIndex = -1; settings.currentWordIndex++; } + settings.currentLetterIndex++; + if (!Config.blindMode) { if (settings.correction < 0) { while (settings.correction < 0) { settings.currentLetterIndex--; - if (settings.currentLetterIndex <= -2) { + if (settings.currentLetterIndex <= -1) { //go to the previous word settings.currentLetterIndex = // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined - TestWords.words.get(settings.currentWordIndex - 1)!.display - .length - 1; + TestWords.words.get(settings.currentWordIndex - 1)!.text.length; settings.currentWordIndex--; } settings.correction++; @@ -208,7 +207,7 @@ function incrementLetterIndex(): void { if ( settings.currentLetterIndex >= // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined - TestWords.words.get(settings.currentWordIndex)!.display.length + TestWords.words.get(settings.currentWordIndex)!.text.length + 1 ) { //go to the next word settings.currentLetterIndex = 0; @@ -235,8 +234,7 @@ export function handleSpace(correct: boolean, currentWord: string): void { !Config.blindMode ) { settings.wordsStatus[TestState.activeWordIndex] = undefined; - settings.correction -= - removeTrailingSeparatorSpace(currentWord).length + 1; + settings.correction -= currentWord.length; } } else { if ( @@ -245,7 +243,7 @@ export function handleSpace(correct: boolean, currentWord: string): void { !Config.blindMode ) { settings.wordsStatus[TestState.activeWordIndex] = true; - settings.correction += currentWord.length + 1; + settings.correction += currentWord.length; } } } diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 225918d677b4..c5f0c8fa3bd0 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -379,9 +379,7 @@ function buildWordHTML(word: string, wordIndex: number): string { const funbox = findSingleActiveFunboxWithFunction("getWordHtml"); // the stored trailing separator space is not rendered as a letter - const chars = Strings.splitIntoCharacters( - Strings.removeTrailingSeparatorSpace(word), - ); + const chars = Strings.splitIntoCharacters(word); for (const char of chars) { if (funbox) { retval += funbox.functions.getWordHtml(char, true); diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index 39b02bbde718..0a455a363e6f 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -357,24 +357,6 @@ export function toHex(buffer: ArrayBuffer): string { return hashHex; } -/** - * Removes the trailing separator space from a target word. Target words store - * the word separator as a trailing space (see test-words.ts); this strips that - * single space to get the bare/visible word. - * - * Only the separator space is removed — not a trailing newline. A trailing - * newline is actual word content: it is rendered as a letter, typed by the - * user to advance, and counted in the word's visible length, whereas the - * separator space is none of those things. Words ending in a newline, the - * final word, and nospace-funbox words have no trailing space, so this is a - * no-op for them. - * @param word The target word. - * @returns The word without its trailing separator space. - */ -export function removeTrailingSeparatorSpace(word: string): string { - return word.endsWith(" ") ? word.slice(0, -1) : word; -} - /** * Checks if a character is a directly typable space character on a standard keyboard. * These are space characters that can be typed without special input methods or copy-pasting. From 26423b46771960a78c5905bae8578d029bb06480 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 19:15:57 +0200 Subject: [PATCH 26/40] remove debug --- frontend/src/ts/test/test-ui.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index c5f0c8fa3bd0..65fd4ff0da47 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -734,12 +734,6 @@ export async function updateWordLetters({ input: string; compositionData: string; }): Promise { - showNoticeNotification(`Updating word letters ${wordIndex}`, { - durationMs: 250, - important: true, - }); - // console.log("updating word letters"); - console.trace(); pendingWordData.set(wordIndex, input); requestDebouncedAnimationFrame( `test-ui.updateWordLetters.${wordIndex}`, From e9625b0f636dc9dcbd542b37521a2b06f9dc2a48 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 19:35:43 +0200 Subject: [PATCH 27/40] fix replay --- frontend/src/ts/test/replay-ui.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/test/replay-ui.ts b/frontend/src/ts/test/replay-ui.ts index 221fc4aeabd2..cffdc7a77974 100644 --- a/frontend/src/ts/test/replay-ui.ts +++ b/frontend/src/ts/test/replay-ui.ts @@ -62,7 +62,7 @@ function deriveReplayActions(): Replay[] { const target = Config.mode === "zen" ? typed - : TestWords.words.get(prevWordIndex)?.text; + : TestWords.words.get(prevWordIndex)?.textWithCommit; const correct = typed === target; actions.push({ action: correct ? "submitCorrectWord" : "submitErrorWord", From 02a1122116b7ec9ac2553908b52a341210ed1e4a Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 19:36:40 +0200 Subject: [PATCH 28/40] use text with commit --- frontend/src/ts/test/practise-words.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index ecc2a1162474..4d1e60c84536 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -63,7 +63,7 @@ export function init( let sortableMissedBiwords: [string, string, number][] = []; if (missed === "biwords") { for (let i = 0; i < TestWords.words.length; i++) { - const missedWord = TestWords.words.get(i)?.text; + const missedWord = TestWords.words.get(i)?.textWithCommit; if (missedWord === undefined) continue; // won't happen, but ts complains From c28107f12facd514d2456975fec04ee714ec6e98 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 20:26:17 +0200 Subject: [PATCH 29/40] trim --- frontend/src/ts/test/events/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/test/events/helpers.ts b/frontend/src/ts/test/events/helpers.ts index 779559c3d366..d236f88a6101 100644 --- a/frontend/src/ts/test/events/helpers.ts +++ b/frontend/src/ts/test/events/helpers.ts @@ -143,7 +143,7 @@ export function getInputFromDom(events: TestEventNoMs[]): string { !data.correct ) { // if this is an incorrect word commit on the last word, we dont want to count it at all - return inputValue; + return inputValue.trimEnd(); } return inputValue; From 0881ed983c746ecdd36c8cb01185ea06a6030ba2 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 20:54:47 +0200 Subject: [PATCH 30/40] correctInsert --- frontend/src/ts/input/handlers/insert-text.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index a4335d845ccc..eaad7aa6e816 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -17,7 +17,6 @@ import { } from "../../utils/strings"; import * as TestState from "../../test/test-state"; import * as TestLogic from "../../test/test-logic"; -import { isFunboxActiveWithProperty } from "../../test/funbox/list"; import { Config } from "../../config/store"; import { flash } from "../../events/keymap"; import * as WeakSpot from "../../test/weak-spot"; @@ -161,10 +160,10 @@ export async function onInsertText(options: OnInsertTextParams): Promise { }); // word navigation check - const noSpaceForce = - isFunboxActiveWithProperty("nospace") && - (testInput + data).length === - TestWords.words.getCurrent()?.textWithCommit.length; + // const noSpaceForce = + // isFunboxActiveWithProperty("nospace") && + // (testInput + data).length === + // TestWords.words.getCurrent()?.textWithCommit.length; // handing cases where last char needs to be removed // this is here and not in beforeInsertText because we want to penalize for incorrect spaces @@ -237,16 +236,12 @@ export async function onInsertText(options: OnInsertTextParams): Promise { TestUI.afterTestTextInput(correct, visualInputOverride); } - const commitCorrect = noSpaceForce - ? testInput + data === currentWord - : correct; - // going to next word let increasedWordIndex: null | boolean = null; let lastBurst: null | number = null; if (goingToNextWord) { const result = await goToNextWord({ - correctInsert: commitCorrect, + correctInsert: testInput + data === currentWord, isCompositionEnding: isCompositionEnding === true, zenNewline: data === "\n" && Config.mode === "zen", now, From 0c02f1a203c9c51593c4d37ac646f2d40eec4a36 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 21:28:01 +0200 Subject: [PATCH 31/40] refactor addWord to use new Word object structure from push method --- frontend/src/ts/test/test-logic.ts | 11 +++++++---- frontend/src/ts/test/test-words.ts | 9 ++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 9720d2e061c6..74f2e65852d9 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -691,8 +691,8 @@ export async function addWord(): Promise { break; } wordCount++; - TestWords.words.push(word, i); - TestUI.addWord(word); + const newWord = TestWords.words.push(word, i); + TestUI.addWord(newWord.display); } } } @@ -705,8 +705,11 @@ export async function addWord(): Promise { TestWords.words.get(TestWords.words.length - 2)?.text, ); - TestWords.words.push(randomWord.word, randomWord.sectionIndex); - TestUI.addWord(randomWord.word); + const newWord = TestWords.words.push( + randomWord.word, + randomWord.sectionIndex, + ); + TestUI.addWord(newWord.display); } catch (e) { timerEvent.dispatch({ key: "fail", value: "word generation error" }); showErrorNotification( diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index adaab94af222..780f6d089b35 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -49,7 +49,7 @@ class Words { getCurrent(): Word | undefined { return this.list[TestState.activeWordIndex]; } - push(word: string, sectionIndex: number): void { + push(word: string, sectionIndex: number): Word { let commit: CommitChar = ""; if (word.endsWith(" ")) { commit = " "; @@ -58,14 +58,17 @@ class Words { commit = "\n"; word = word.slice(0, -1); } - this.list.push({ + const wordObj = { text: word, textWithCommit: word + commit, commit, display: word + (commitCharsToDisplay.has(commit) ? commit : ""), sectionIndex, - }); + }; + this.list.push(wordObj); this.length = this.list.length; + + return wordObj; } reset(): void { From d7c5f4fab2f6e2e55c28b77aaa213c5b5dcf8783 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 21:29:26 +0200 Subject: [PATCH 32/40] fix --- frontend/src/ts/test/test-words.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index 780f6d089b35..10e6631bdd89 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -86,6 +86,7 @@ class Words { ) { lastWord.textWithCommit = lastWord.textWithCommit.slice(0, -1); lastWord.display = lastWord.textWithCommit; + lastWord.commit = ""; } } } From cb4335074397fe06e15ea4fd77862d9ee8fa4389 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 21:30:09 +0200 Subject: [PATCH 33/40] cleanup --- frontend/src/ts/input/handlers/insert-text.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index eaad7aa6e816..4d929779b62c 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -159,12 +159,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { correctShiftUsed, }); - // word navigation check - // const noSpaceForce = - // isFunboxActiveWithProperty("nospace") && - // (testInput + data).length === - // TestWords.words.getCurrent()?.textWithCommit.length; - // handing cases where last char needs to be removed // this is here and not in beforeInsertText because we want to penalize for incorrect spaces // like accuracy, keypress errors, and missed words From d70afb7d1c9442942767a955cafa39852a7d7795 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 21:52:21 +0200 Subject: [PATCH 34/40] cleanup --- frontend/src/ts/input/handlers/insert-text.ts | 5 ++--- frontend/src/ts/input/helpers/word-navigation.ts | 11 +---------- frontend/src/ts/test/test-ui.ts | 12 ++++-------- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 4d929779b62c..8deff974eca2 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -235,9 +235,8 @@ export async function onInsertText(options: OnInsertTextParams): Promise { let lastBurst: null | number = null; if (goingToNextWord) { const result = await goToNextWord({ - correctInsert: testInput + data === currentWord, - isCompositionEnding: isCompositionEnding === true, - zenNewline: data === "\n" && Config.mode === "zen", + correctInsert: + Config.mode === "zen" ? true : testInput + data === currentWord, now, }); lastBurst = result.lastBurst; diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index ec24be01e7f4..d3c446cb35cd 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -18,9 +18,6 @@ import { buildEventLog, getInputForWord } from "../../test/events/data"; type GoToNextWordParams = { correctInsert: boolean; - // this is used to tell test ui to update the word before moving to the next word (in case of a composition that ends with a space) - isCompositionEnding: boolean; - zenNewline?: boolean; now: number; }; @@ -31,8 +28,6 @@ type GoToNextWordReturn = { export async function goToNextWord({ correctInsert, - isCompositionEnding, - zenNewline, now, }: GoToNextWordParams): Promise { const ret: GoToNextWordReturn = { @@ -40,11 +35,7 @@ export async function goToNextWord({ lastBurst: null, }; - TestUI.beforeTestWordChange( - "forward", - correctInsert, - isCompositionEnding || zenNewline === true, - ); + TestUI.beforeTestWordChange("forward", correctInsert); for (const fb of getActiveFunboxesWithFunction("handleSpace")) { fb.functions.handleSpace(); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 65fd4ff0da47..8d698c497b65 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -734,6 +734,8 @@ export async function updateWordLetters({ input: string; compositionData: string; }): Promise { + console.log("updateWordLetters", { wordIndex, input, compositionData }); + console.trace(); pendingWordData.set(wordIndex, input); requestDebouncedAnimationFrame( `test-ui.updateWordLetters.${wordIndex}`, @@ -1820,19 +1822,13 @@ export function afterTestDelete(): void { export function beforeTestWordChange( direction: "forward", correct: boolean, - forceUpdateActiveWordLetters?: boolean, -): void; -export function beforeTestWordChange( - direction: "back", - correct: null, - forceUpdateActiveWordLetters?: boolean, ): void; +export function beforeTestWordChange(direction: "back", correct: null): void; export function beforeTestWordChange( direction: "forward" | "back", correct: boolean | null, - forceUpdateActiveWordLetters?: boolean, // this param is very likely not needed ): void { - if (direction === "back" || forceUpdateActiveWordLetters) { + if (direction === "back") { void updateWordLetters({ input: getCurrentInputForDisplay(), wordIndex: TestState.activeWordIndex, From ced40f5ca150428b6ccc492604f9cadb2f11cfaf Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 21:57:17 +0200 Subject: [PATCH 35/40] remove comment --- frontend/src/ts/test/test-ui.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 8d698c497b65..10dfa080800d 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -378,7 +378,6 @@ function buildWordHTML(word: string, wordIndex: number): string { let retval = `
`; const funbox = findSingleActiveFunboxWithFunction("getWordHtml"); - // the stored trailing separator space is not rendered as a letter const chars = Strings.splitIntoCharacters(word); for (const char of chars) { if (funbox) { From 474295a34421a041b118618dfacb0ead2cd35948 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 22:50:02 +0200 Subject: [PATCH 36/40] fix --- frontend/__tests__/test/events/stats.spec.ts | 12 +++++++++++- frontend/src/ts/test/test-ui.ts | 2 -- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 3d8c41a3d034..133268544944 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -90,9 +90,19 @@ import { Config } from "../../../src/ts/config/store"; import { Keycode } from "../../../src/ts/constants/keys"; import * as TestState from "../../../src/ts/test/test-state"; import { words as TestWords } from "../../../src/ts/test/test-words"; +import { isFunboxActiveWithProperty } from "../../../src/ts/test/funbox/list"; +// mirror the generator: each word carries a trailing space separator unless it +// already ends with a newline, the nospace funbox is active, or it's the last +// word (the final separator is stripped once all words are generated) function pushWords(...words: string[]): void { - words.forEach((word, i) => TestWords.push(word, i)); + const nospace = isFunboxActiveWithProperty("nospace"); + words.forEach((word, i) => { + const isLast = i === words.length - 1; + const withSeparator = + isLast || nospace || word.endsWith("\n") ? word : `${word} `; + TestWords.push(withSeparator, i); + }); } function keyDown(code: Keycode = "KeyA"): KeydownEventData { diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 10dfa080800d..6179a47a94cd 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -733,8 +733,6 @@ export async function updateWordLetters({ input: string; compositionData: string; }): Promise { - console.log("updateWordLetters", { wordIndex, input, compositionData }); - console.trace(); pendingWordData.set(wordIndex, input); requestDebouncedAnimationFrame( `test-ui.updateWordLetters.${wordIndex}`, From f5484c7d5c5b147911b72a19f9d7fd152060accc Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 22:54:11 +0200 Subject: [PATCH 37/40] trim --- frontend/src/ts/test/events/stats.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 041203ca0d01..cf67199765e7 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -870,7 +870,8 @@ export function getMissedWords(eventLog: EventLog): Record { ) { const word = eventLog.context.targetWords[event.data.wordIndex]; if (word === undefined) continue; - const bareWord = word; + // targetWords store the trailing separator (commit char); key by the bare word + const bareWord = word.trimEnd(); missedWords[bareWord] = (missedWords[bareWord] ?? 0) + 1; } } From 5248a7ce3bc7f380c63563c5597bdc3203865014 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 23:02:21 +0200 Subject: [PATCH 38/40] use text --- frontend/src/ts/test/practise-words.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index 4d1e60c84536..ecc2a1162474 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -63,7 +63,7 @@ export function init( let sortableMissedBiwords: [string, string, number][] = []; if (missed === "biwords") { for (let i = 0; i < TestWords.words.length; i++) { - const missedWord = TestWords.words.get(i)?.textWithCommit; + const missedWord = TestWords.words.get(i)?.text; if (missedWord === undefined) continue; // won't happen, but ts complains From 3db589c96c63a35b9c97e830f75fde868ff32e97 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 23:09:51 +0200 Subject: [PATCH 39/40] fix opposite shift --- frontend/src/ts/input/handlers/insert-text.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 8deff974eca2..5dd57ea2eeed 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -171,15 +171,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { removeLastChar = true; } - const goingToNextWord = - !removeLastChar && - shouldGoToNextWord({ - data, - inputValue: testInput, - targetWord: currentWord, - isCommitCharacter: isCommit, - }); - if (correctShiftUsed === false) { removeLastChar = true; visualInputOverride = undefined; @@ -194,6 +185,16 @@ export async function onInsertText(options: OnInsertTextParams): Promise { resetIncorrectShiftsInARow(); } + // derived after removeLastChar: stop-on-error and opposite shift mode can block navigation + const goingToNextWord = + !removeLastChar && + shouldGoToNextWord({ + data, + inputValue: testInput, + targetWord: currentWord, + isCommitCharacter: isCommit, + }); + if (Config.keymapMode === "react") { flash(data, correct); } From 76076c5098ae0113fe8140b7a5973412155cc8fb Mon Sep 17 00:00:00 2001 From: Miodec Date: Sat, 27 Jun 2026 23:13:35 +0200 Subject: [PATCH 40/40] fix --- frontend/src/ts/test/test-logic.ts | 5 ++++- frontend/src/ts/test/words-generator.ts | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 74f2e65852d9..dd3fad611f52 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -691,7 +691,10 @@ export async function addWord(): Promise { break; } wordCount++; - const newWord = TestWords.words.push(word, i); + const newWord = TestWords.words.push( + WordsGenerator.appendCommitCharacter(word), + i, + ); TestUI.addWord(newWord.display); } } diff --git a/frontend/src/ts/test/words-generator.ts b/frontend/src/ts/test/words-generator.ts index 6d339d4083d5..cd51f7f6f800 100644 --- a/frontend/src/ts/test/words-generator.ts +++ b/frontend/src/ts/test/words-generator.ts @@ -971,9 +971,7 @@ export async function getNextWord( console.debug("Word:", randomWord); - if (!randomWord.endsWith("\n") && !isFunboxActiveWithProperty("nospace")) { - randomWord = `${randomWord} `; - } + randomWord = appendCommitCharacter(randomWord); const ret = { word: randomWord, @@ -985,6 +983,19 @@ export async function getNextWord( return ret; } +/** + * Appends the inter-word commit separator the way the generator does: a trailing + * space, unless the word already ends with a newline or the nospace funbox is + * active. Callers that push words outside of getNextWord (e.g. section funbox + * pulls) must use this so the separator is part of the target word. + */ +export function appendCommitCharacter(word: string): string { + if (word.endsWith("\n") || isFunboxActiveWithProperty("nospace")) { + return word; + } + return `${word} `; +} + export function areAllWordsGenerated(): boolean { return ( (Config.mode === "words" &&