diff --git a/frontend/__tests__/input/helpers/validation.spec.ts b/frontend/__tests__/input/helpers/validation.spec.ts index 59c7bf5a145f..681394cb863e 100644 --- a/frontend/__tests__/input/helpers/validation.spec.ts +++ b/frontend/__tests__/input/helpers/validation.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; import { isCharCorrect, + isWordCorrect, shouldInsertSpaceCharacter, } from "../../../src/ts/input/helpers/validation"; import { __testing } from "../../../src/ts/config/testing"; @@ -75,6 +76,29 @@ describe("isCharCorrect", () => { }); describe("Space Handling", () => { + it.each([ + ["returns false in the middle of a word", " ", "wor", "word", false], + ["returns false at the start of a word", " ", "", "word", false], + [ + "returns false when longer than a word", + " ", + "wordwordword", + "word", + false, + ], + ])("%s", (_desc, char, input, word, expected) => { + expect( + isCharCorrect({ + data: char, + inputValue: input, + targetWord: word, + correctShiftUsed: true, + }), + ).toBe(expected); + }); + }); + + describe("Space Handling at the end of a word", () => { it.each([ ["returns true at the end of a correct word", " ", "word", "word", true], [ @@ -84,18 +108,23 @@ describe("isCharCorrect", () => { "word", false, ], - ["returns false in the middle of a word", " ", "wor", "word", false], - ["returns false at the start of a word", " ", "", "word", false], [ - "returns false when longer than a word", - " ", - "wordwordword", + "returns true when committing a word with a newline", + "\n", "word", + "word\n", + true, + ], + [ + "returns false when committing an incorrect word with a newline", + "\n", + "xord", + "word\n", false, ], ])("%s", (_desc, char, input, word, expected) => { expect( - isCharCorrect({ + isWordCorrect({ data: char, inputValue: input, targetWord: word, @@ -124,17 +153,6 @@ describe("isCharCorrect", () => { }, ); }); - - it("throws error if data is undefined", () => { - expect(() => - isCharCorrect({ - data: undefined as any, - inputValue: "val", - targetWord: "word", - correctShiftUsed: true, - }), - ).toThrow(); - }); }); describe("shouldInsertSpaceCharacter", () => { diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index b60d863e689c..70ae26173981 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 { isCharCorrect, + isWordCorrect, shouldInsertSpaceCharacter, } from "../helpers/validation"; import { getCurrentInput, logTestEvent } from "../../test/events/data"; @@ -134,7 +135,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { data, currentWord[(testInput + data).length - 1] ?? "", ); - const correct = + const charCorrect = funboxCorrect ?? isCharCorrect({ data, @@ -143,6 +144,26 @@ export async function onInsertText(options: OnInsertTextParams): Promise { correctShiftUsed, }); + // word navigation check + 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 (a correct word-completing + // space has charCorrect === false, so charCorrect can't be used below) + 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 // like accuracy, keypress errors, and missed words @@ -155,7 +176,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { removeLastChar = true; } - if (!isSpace(data) && correctShiftUsed === false) { + if (!charIsSpace && correctShiftUsed === false) { removeLastChar = true; visualInputOverride = undefined; incrementIncorrectShiftsInARow(); @@ -169,13 +190,8 @@ export async function onInsertText(options: OnInsertTextParams): Promise { resetIncorrectShiftsInARow(); } - // word navigation check - const noSpaceForce = - isFunboxActiveWithProperty("nospace") && - (testInput + data).length === TestWords.words.getCurrentText().length; - const shouldGoToNextWord = - !removeLastChar && - (((charIsSpace || charIsNewline) && !shouldInsertSpace) || noSpaceForce); + // 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); @@ -209,12 +225,16 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // this needs to be called after event logging WeakSpot.updateScore(data, correct); + const commitCorrect = noSpaceForce + ? testInput + data === currentWord + : correct; + // going to next word let increasedWordIndex: null | boolean = null; let lastBurst: null | number = null; if (shouldGoToNextWord) { const result = await goToNextWord({ - correctInsert: correct, + correctInsert: commitCorrect, isCompositionEnding: isCompositionEnding === true, zenNewline: charIsNewline && Config.mode === "zen", now, diff --git a/frontend/src/ts/input/helpers/validation.ts b/frontend/src/ts/input/helpers/validation.ts index 58b7580a4fa3..a2712335b3b2 100644 --- a/frontend/src/ts/input/helpers/validation.ts +++ b/frontend/src/ts/input/helpers/validation.ts @@ -18,28 +18,37 @@ export function isCharCorrect(options: { const { data, inputValue, targetWord, correctShiftUsed } = options; if (Config.mode === "zen") return true; - if (correctShiftUsed === false) return false; - if (data === undefined) { - throw new Error("Failed to check if char is correct - data is undefined"); - } - - if (isSpace(data)) { - return inputValue === targetWord; - } - const targetChar = targetWord[inputValue.length]; if (targetChar === undefined) { return false; } - if (data === targetChar) { - return true; - } + return data === targetChar; +} + +/** + * Check if the input data is correct + * @param options - Options object + * @param options.inputValue - Current input value (use getCurrentInput(), not input element value) + * @param options.targetWord - Target word + * @param options.correctShiftUsed - Whether the correct shift state was used. Null means disabled + */ +export function isWordCorrect(options: { + data: string; + inputValue: string; + targetWord: string; + correctShiftUsed: boolean | null; //null means disabled +}): boolean { + const { data, inputValue, targetWord, correctShiftUsed } = options; + + if (Config.mode === "zen") return true; + if (correctShiftUsed === false) return false; - return false; + const finalInputValue = inputValue + (isSpace(data) ? "" : data); + return finalInputValue === targetWord; } /** diff --git a/frontend/src/ts/test/events/data.ts b/frontend/src/ts/test/events/data.ts index b9e12b7dd2d2..91884985bec0 100644 --- a/frontend/src/ts/test/events/data.ts +++ b/frontend/src/ts/test/events/data.ts @@ -220,6 +220,26 @@ export function getCurrentInput(): string { return getInputFromDom(getEventsForWord(getAllTestEvents(), activeWordIndex)); } +// Like getCurrentInput, but strips the trailing space when the last input +// event committed the word (advanced to the next word) rather than inserting +// a literal space. The committing space is a word separator, not part of the +// word's input, so the UI must not render it. Newline commits are kept +// since the newline is visible content. +export function getCurrentInputForDisplay(): string { + const input = getCurrentInput(); + const last = inputEvents[inputEvents.length - 1]; + if ( + last !== undefined && + last.data.wordIndex === activeWordIndex && + "commitsWord" in last.data && + last.data.commitsWord === true && + last.data.data === " " + ) { + return input.slice(0, -1); + } + return input; +} + export function getInputForWord(wordIndex: number): string { return getInputFromDom( getEventsForWord(getAllTestEvents(), wordIndex), diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 77222524f517..3035080af0c3 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -6,7 +6,7 @@ import { import { Config } from "../config/store"; import { setConfig } from "../config/setters"; import * as TestWords from "./test-words"; -import { getCurrentInput } from "./events/data"; +import { getCurrentInput, getCurrentInputForDisplay } from "./events/data"; import { getLiveCachedAccuracy } from "./events/live-cache"; import * as CustomText from "./custom-text"; import * as Caret from "./caret"; @@ -1789,7 +1789,7 @@ export function afterTestTextInput( if (!increasedWordIndex) { void updateWordLetters({ - input: inputOverride ?? getCurrentInput(), + input: inputOverride ?? getCurrentInputForDisplay(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -1800,7 +1800,7 @@ export function afterTestTextInput( export function afterTestCompositionUpdate(): void { void updateWordLetters({ - input: getCurrentInput(), + input: getCurrentInputForDisplay(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -1810,7 +1810,7 @@ export function afterTestCompositionUpdate(): void { export function afterTestDelete(): void { void updateWordLetters({ - input: getCurrentInput(), + input: getCurrentInputForDisplay(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -1839,7 +1839,7 @@ export function beforeTestWordChange( forceUpdateActiveWordLetters ) { void updateWordLetters({ - input: getCurrentInput().trimEnd(), + input: getCurrentInputForDisplay(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), }); @@ -2078,7 +2078,7 @@ configEvent.subscribe(({ key, newValue }) => { if (key === "highlightMode") { if (getActivePage() === "test") { void updateWordLetters({ - input: getCurrentInput(), + input: getCurrentInputForDisplay(), wordIndex: TestState.activeWordIndex, compositionData: CompositionState.getData(), });