diff --git a/frontend/__tests__/input/helpers/fail-or-finish.spec.ts b/frontend/__tests__/input/helpers/fail-or-finish.spec.ts index 0d9faaaaf21f..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(() => { @@ -322,7 +354,7 @@ describe("checkIfFinished", () => { allWordsTyped: true, testInputWithData: "wo ", currentWord: "word", - shouldGoToNextWord: true, + goingToNextWord: true, expected: true, }, { @@ -336,7 +368,7 @@ describe("checkIfFinished", () => { desc: string; allWordsTyped: boolean; allWordsGenerated?: boolean; - shouldGoToNextWord: boolean; + goingToNextWord: boolean; testInputWithData: string; currentWord: string; config?: Record; @@ -347,7 +379,7 @@ describe("checkIfFinished", () => { ({ allWordsTyped, allWordsGenerated, - shouldGoToNextWord, + goingToNextWord, testInputWithData, currentWord, config, @@ -356,7 +388,7 @@ describe("checkIfFinished", () => { if (config) replaceConfig(config as any); const result = checkIfFinished({ - shouldGoToNextWord, + goingToNextWord, testInputWithData, currentWord, allWordsTyped, diff --git a/frontend/__tests__/input/helpers/validation.spec.ts b/frontend/__tests__/input/helpers/validation.spec.ts index 681394cb863e..94b966a10634 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, + "word ", + 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,127 +173,141 @@ describe("shouldInsertSpaceCharacter", () => { replaceConfig({}); }); - it("returns null if data is not a space", () => { + it("returns false when the input is not a commit character", () => { expect( - shouldInsertSpaceCharacter({ + shouldGoToNextWord({ data: "a", inputValue: "test", - targetWord: "test", + targetWord: "test ", + isCommitCharacter: false, }), - ).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 ", + isCommitCharacter: true, }), - ).toBe(false); + ).toBe(true); + }); + + it("returns true when committing a word with a newline", () => { + expect( + shouldGoToNextWord({ + data: "\n", + inputValue: "word", + targetWord: "word\n", + isCommitCharacter: true, + }), + ).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, + isCommitCharacter: true, }), ).toBe(expected); }); diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 5569188a25de..cc1805df8a33 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)); @@ -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)); @@ -1227,7 +1228,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..b0fc6ad6d95f --- /dev/null +++ b/frontend/__tests__/test/test-words.spec.ts @@ -0,0 +1,58 @@ +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(); + }); + + 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("tracks length and section indexes", () => { + words.push("a ", 3); + words.push("b", 5); + expect(words.length).toBe(2); + expect(words.sectionIndexList).toEqual([3, 5]); + }); + }); + + 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("strips a trailing newline from the last word", () => { + words.push("line\n", 0); + 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("does nothing on an empty list", () => { + expect(() => words.removeCommitCharacterFromLastWord()).not.toThrow(); + 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..c69beaa41d81 100644 --- a/frontend/src/ts/commandline/lists/result-screen.ts +++ b/frontend/src/ts/commandline/lists/result-screen.ts @@ -148,7 +148,7 @@ 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).join(""); navigator.clipboard.writeText(words).then( () => { diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index 15c326971653..fa705936c77d 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -1,11 +1,14 @@ 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, + removeTrailingSeparatorSpace, +} 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,7 +290,11 @@ export class Caret { const word = wordsCache.qs( `.word[data-wordindex="${options.wordIndex}"]`, ); - const wordText = TestWords.words.getText(options.wordIndex) ?? ""; + + const wordText = removeTrailingSeparatorSpace( + 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..b237abf4b2d4 100644 --- a/frontend/src/ts/input/handlers/before-delete.ts +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -52,7 +52,7 @@ export function onBeforeDelete(event: InputEvent): void { const confidence = Config.confidenceMode; const previousWordCorrect = getInputForWord(TestState.activeWordIndex - 1) === - 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/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 3c5f56419e6e..0ba18b6a683f 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -1,15 +1,15 @@ 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"; import { isFunboxActiveWithProperty } from "../../test/funbox/list"; -import { isSpace } from "../../utils/strings"; +import { isSpace, removeTrailingSeparatorSpace } 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"; +import { shouldGoToNextWord } from "../helpers/validation"; +import { isCommitCharacter } from "../helpers/util"; /** * Handles logic before inserting text into the input element. @@ -31,11 +31,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 @@ -61,8 +56,22 @@ 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; - if (overLimit && (shouldInsertSpaceAsCharacter === true || !dataIsSpace)) { + const overLimit = inputValue.length >= inputLimit; + const targetWord = TestWords.words.getCurrentText(); + const isCommit = isCommitCharacter({ + data, + inputValue, + targetWord, + }); + if ( + overLimit && + !shouldGoToNextWord({ + data, + inputValue, + targetWord, + isCommitCharacter: isCommit, + }) + ) { console.error("Hitting word limit"); return true; } @@ -71,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 = - getCurrentInput().length >= TestWords.words.getCurrentText().length; + inputValue.length >= removeTrailingSeparatorSpace(targetWord).length; if ( !SlowTimer.get() && // don't do this check if slow timer is active @@ -79,7 +88,7 @@ export function onBeforeInsertText(data: string): boolean { !Config.blindMode && !Config.hideExtraLetters && inputIsLongerThanOrEqualToWord && - (shouldInsertSpaceAsCharacter === true || !dataIsSpace) && + !isCommit && Config.mode !== "zen" ) { // make sure to only check this when really necessary @@ -91,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 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 b5a9b4f13a0a..f6147b22cc26 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"; @@ -32,12 +31,10 @@ 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"; +import { isCommitCharacter } from "../helpers/util"; +import { areAllWordsGenerated } from "../../test/words-generator"; const charOverrides = new Map([ ["…", "..."], @@ -145,19 +142,16 @@ 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 shouldInsertSpace = - shouldInsertSpaceCharacter({ - data, - inputValue: testInput, - targetWord: currentWord, - }) === true; const correctShiftUsed = Config.oppositeShiftMode === "off" ? null : isCorrectShiftUsed(); + const isCommit = isCommitCharacter({ + data, + inputValue: testInput, + targetWord: currentWord, + }); // is char correct - const charCorrect = isCharCorrect({ + const correct = isCharCorrect({ data, inputValue: testInput, targetWord: currentWord, @@ -168,20 +162,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 (a correct word-completing - // space has charCorrect === false, so charCorrect can't be used below) - const correct = goingToNextWord - ? 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 @@ -195,7 +175,16 @@ export async function onInsertText(options: OnInsertTextParams): Promise { removeLastChar = true; } - if (!charIsSpace && correctShiftUsed === false) { + const goingToNextWord = + !removeLastChar && + shouldGoToNextWord({ + data, + inputValue: testInput, + targetWord: currentWord, + isCommitCharacter: isCommit, + }); + + if (correctShiftUsed === false) { removeLastChar = true; visualInputOverride = undefined; incrementIncorrectShiftsInARow(); @@ -209,9 +198,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); } @@ -237,13 +223,17 @@ 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, }); // 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; @@ -251,11 +241,11 @@ 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, - zenNewline: charIsNewline && Config.mode === "zen", + zenNewline: data === "\n" && Config.mode === "zen", now, }); lastBurst = result.lastBurst; @@ -263,13 +253,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 @@ -292,9 +282,11 @@ 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, + isCommitCharacter: isCommit, }) ) { TestLogic.fail("difficulty"); @@ -309,20 +301,16 @@ export async function onInsertText(options: OnInsertTextParams): Promise { TestLogic.fail("min burst"); } else if ( checkIfFinished({ - shouldGoToNextWord, + goingToNextWord, testInputWithData: testInput + data, currentWord, allWordsTyped: wordIndex >= TestWords.words.length - 1, - allWordsGenerated: TestLogic.areAllTestWordsGenerated(), + allWordsGenerated: areAllWordsGenerated(), }) ) { void TestLogic.finish(); } } - - if (lastInMultiOrSingle) { - TestUI.afterTestTextInput(correct, increasedWordIndex, visualInputOverride); - } } function normalizeDataAndUpdateInputIfNeeded( diff --git a/frontend/src/ts/input/helpers/fail-or-finish.ts b/frontend/src/ts/input/helpers/fail-or-finish.ts index 6c096a42c10b..4329a33904c2 100644 --- a/frontend/src/ts/input/helpers/fail-or-finish.ts +++ b/frontend/src/ts/input/helpers/fail-or-finish.ts @@ -41,11 +41,13 @@ 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; + isCommitCharacter: boolean; }): boolean { - const { testInputWithData, correct, spaceOrNewline } = 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 @@ -53,9 +55,10 @@ export function checkIfFailedDueToDifficulty(options: { const shouldFailDueToExpert = Config.difficulty === "expert" && - !correct && - spaceOrNewline && - testInputWithData.length > 1; + isCommitCharacter && + // a leading separator (empty input) commits nothing and must not fail + testInput.length > 0 && + testInput + data !== targetWord; const shouldFailDueToMaster = Config.difficulty === "master" && !correct; @@ -68,21 +71,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 +99,7 @@ export function checkIfFinished(options: { if ( allWordsTyped && allWordsGenerated && - (wordIsCorrect || shouldQuickEnd || shouldGoToNextWord) + (wordIsCorrect || shouldQuickEnd || goingToNextWord) ) { return true; } 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 a2712335b3b2..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 { isSpace } from "../../utils/strings"; /** * Check if the input data is correct @@ -30,57 +29,42 @@ 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 + isCommitCharacter: boolean; }): boolean { - const { data, inputValue, targetWord, correctShiftUsed } = options; + const { inputValue, targetWord, data, isCommitCharacter } = options; + + if (!isCommitCharacter) return false; if (Config.mode === "zen") return true; - if (correctShiftUsed === false) return false; - const finalInputValue = inputValue + (isSpace(data) ? "" : data); - return finalInputValue === 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; } - const correctSoFar = `${targetWord} `.startsWith(`${inputValue} `); - 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; } diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index 947d77541858..cd1c63133e0b 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 { removeTrailingSeparatorSpace } from "../../utils/strings"; type GoToNextWordParams = { correctInsert: boolean; @@ -84,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(); @@ -104,7 +102,9 @@ export function goToPreviousWord( if (inputType === "deleteWordBackward") { setInputElementValue(""); } else if (inputType === "deleteContentBackward") { - const word = getInputForWord(TestState.activeWordIndex); + const word = removeTrailingSeparatorSpace( + getInputForWord(TestState.activeWordIndex), + ); if (nospaceEnabled) { setInputElementValue(word.slice(0, -1)); } else if (word.endsWith("\n")) { diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index c080c0dee8d2..17d9d1aa49a7 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -13,8 +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 { removeTrailingSeparatorSpace } from "../../utils/strings"; +import { areAllWordsGenerated } from "../../test/words-generator"; const inputEl = getInputElement(); @@ -129,14 +130,15 @@ inputEl.addEventListener("input", async (event) => { const inputPlusComposition = getCurrentInput() + (CompositionState.getData() ?? ""); const inputPlusCompositionIsCorrect = - TestWords.words.getCurrentText() === inputPlusComposition; + removeTrailingSeparatorSpace(TestWords.words.getCurrentText()) === + inputPlusComposition; // composition quick end // if the user typed the entire word correctly but is still in composition // 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/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/events/stats.ts b/frontend/src/ts/test/events/stats.ts index f9f7f3632128..bb0f09fb3f05 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -1,4 +1,9 @@ -import { CharCounts, countChars, isSpace } from "../../utils/strings"; +import { + CharCounts, + countChars, + isSpace, + removeTrailingSeparatorSpace, +} from "../../utils/strings"; import { getEventsForWord, getEventsPerWord, getInputFromDom } from "./helpers"; import { calculateWpm } from "../../utils/numbers"; import { roundTo2 } from "@monkeytype/util/numbers"; @@ -357,22 +362,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 ? removeTrailingSeparatorSpace(word) : word; } } @@ -897,7 +889,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 = removeTrailingSeparatorSpace(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 73d6422c6cfb..ecb3571545fd 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -63,7 +63,7 @@ async function readAheadHandleKeydown(event: KeyboardEvent): Promise { !isCorrect && (currentInput !== "" || getInputForWord(TestState.activeWordIndex - 1) !== - 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/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 27244a9b6452..d857fb2828eb 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 { removeTrailingSeparatorSpace } from "../utils/strings"; import { Config } from "../config/store"; import * as DB from "../db"; import { getActiveTagsPB } from "../collections/tags"; @@ -173,6 +174,13 @@ 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 removeTrailingSeparatorSpace(TestWords.words.getText(wordIndex)) + .length; +} + function incrementLetterIndex(): void { if (settings === null) return; @@ -180,7 +188,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 +201,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 +211,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 +238,8 @@ export function handleSpace(correct: boolean, currentWord: string): void { !Config.blindMode ) { settings.wordsStatus[TestState.activeWordIndex] = undefined; - settings.correction -= 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 49a384d8f8bd..626f9e24d17e 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 { removeTrailingSeparatorSpace } 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 = removeTrailingSeparatorSpace( + 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), + 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 ac0e43ada6f6..4c3a6a0e8beb 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 { removeTrailingSeparatorSpace } 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(removeTrailingSeparatorSpace); } function deriveReplayActions(): Replay[] { @@ -59,7 +60,7 @@ function deriveReplayActions(): Replay[] { const target = Config.mode === "zen" ? typed - : TestWords.words.getText(prevWordIndex); + : (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..e9380e5e0443 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -609,6 +609,10 @@ async function init(): Promise { ); } + if (WordsGenerator.areAllWordsGenerated()) { + TestWords.words.removeCommitCharacterFromLastWord(); + } + if (Config.keymapMode === "next" && Config.mode !== "zen") { highlight( Arrays.nthElementFromArray( @@ -629,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, @@ -637,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") { @@ -677,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; } @@ -713,11 +698,17 @@ 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 ?? "", + prevWord2, ); TestWords.words.push(randomWord.word, randomWord.sectionIndex); @@ -732,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 = { @@ -1079,7 +1076,12 @@ export async function finish(difficultyFailed = false): Promise { const lastWordInputLength = history[wordIndex]?.length ?? 0; - if (lastWordInputLength < TestWords.words.getText(wordIndex).length) { + if ( + lastWordInputLength < + Strings.removeTrailingSeparatorSpace( + 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..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 { @@ -378,7 +375,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.removeTrailingSeparatorSpace(word), + ); for (const char of chars) { if (funbox) { retval += funbox.functions.getWordHtml(char, true); @@ -731,12 +731,21 @@ 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}`, 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.removeTrailingSeparatorSpace( + TestWords.words.getText(wordIndex) ?? "", + ); if (!currentWord && Config.mode !== "zen") return; let ret = ""; const wordAtIndex = getWordElement(wordIndex); @@ -1333,7 +1342,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.removeTrailingSeparatorSpace( + TestWords.words.getText(i) ?? "", + ); const koreanRegex = /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/; const containsKorean = @@ -1779,7 +1790,6 @@ function afterAnyTestInput( export function afterTestTextInput( correct: boolean, - increasedWordIndex: boolean | null, inputOverride?: string, ): void { //nospace cant be handled here becauseword index @@ -1787,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); } @@ -1820,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, diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index b8ddae67d6ff..3f87e8e9e392 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -11,25 +11,17 @@ class Words { this.length = 0; } - 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 { this.list.push(word); this.sectionIndexList.push(sectionIndex); @@ -41,16 +33,13 @@ 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); - } - } + + 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); } } } diff --git a/frontend/src/ts/test/words-generator.ts b/frontend/src/ts/test/words-generator.ts index 95895c4ddbfd..0bdb5550c537 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.removeTrailingSeparatorSpace(previousWord); + if (previousWord2 !== undefined) { + previousWord2 = Strings.removeTrailingSeparatorSpace(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) + ); +} diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index 0a455a363e6f..39b02bbde718 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -357,6 +357,24 @@ 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.