diff --git a/frontend/__tests__/input/helpers/fail-or-finish.spec.ts b/frontend/__tests__/input/helpers/fail-or-finish.spec.ts index 0d9faaaaf21f..fb6a8fae14d6 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 ", + commitCharacterType: "separator", expected: false, }, { desc: "zen mode - never fails", config: { mode: "zen", difficulty: "normal" }, correct: false, - spaceOrNewline: true, - input: "hello", + data: " ", + testInput: "hello", + targetWord: "hello ", + commitCharacterType: "separator", 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", + commitCharacterType: false, expected: false, }, { desc: "normal typing space incorrect - never fails", config: { difficulty: "normal" }, correct: false, - spaceOrNewline: true, - input: "hello", + data: " ", + testInput: "hell", + targetWord: "hello ", + commitCharacterType: "separator", expected: false, }, { desc: "normal typing correct - never fails", config: { difficulty: "normal" }, correct: true, - spaceOrNewline: false, - input: "hello", + data: "o", + testInput: "hell", + targetWord: "hello", + commitCharacterType: false, expected: false, }, { desc: "normal typing space correct - never fails", config: { difficulty: "normal" }, correct: true, - spaceOrNewline: true, - input: "hello", + data: " ", + testInput: "hello", + targetWord: "hello ", + commitCharacterType: "separator", expected: false, }, // @@ -189,76 +194,140 @@ describe("checkIfFailedDueToDifficulty", () => { desc: "expert - fail if incorrect space", config: { difficulty: "expert" }, correct: false, - spaceOrNewline: true, - input: "he", + data: " ", + testInput: "he", + targetWord: "hello ", + commitCharacterType: "separator", expected: true, }, { desc: "expert - dont fail if space is the first character", config: { difficulty: "expert" }, correct: false, - spaceOrNewline: true, - input: " ", + data: " ", + testInput: "", + targetWord: "hello ", + commitCharacterType: "separator", expected: false, }, { desc: "expert: - dont fail if just typing", config: { difficulty: "expert" }, correct: false, - spaceOrNewline: false, - input: "h", + data: "h", + testInput: "hell", + targetWord: "hello", + commitCharacterType: 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", + commitCharacterType: false, + expected: false, + }, + { + // nospace commits on the final letter (empty input for a 1-letter word); + // an incorrect commit must still fail expert + desc: "expert - fail on incorrect nospace 1-letter word on empty input", + config: { difficulty: "expert" }, + correct: false, + data: "b", + testInput: "", + targetWord: "a", + commitCharacterType: "nospace", + expected: true, + }, + { + desc: "expert - dont fail on correct nospace 1-letter word on empty input", + config: { difficulty: "expert" }, + correct: true, + data: "a", + testInput: "", + targetWord: "a", + commitCharacterType: "nospace", expected: false, }, + { + desc: "expert - fail on incorrect nospace multi-letter word commit", + config: { difficulty: "expert" }, + correct: false, + data: "o", + testInput: "helx", + targetWord: "hello", + commitCharacterType: "nospace", + expected: true, + }, // { desc: "master - fail if incorrect char", config: { difficulty: "master" }, correct: false, - spaceOrNewline: false, - input: "h", + data: "h", + testInput: "hell", + targetWord: "hello", + commitCharacterType: false, expected: true, }, { desc: "master - fail if incorrect first space", config: { difficulty: "master" }, correct: true, - spaceOrNewline: true, - input: " ", + data: " ", + testInput: "", + targetWord: "hello ", + commitCharacterType: "separator", expected: false, }, { desc: "master - dont fail if correct char", config: { difficulty: "master" }, correct: true, - spaceOrNewline: false, - input: "a", + data: "a", + testInput: "te", + targetWord: "tea", + commitCharacterType: false, expected: false, }, { desc: "master - dont fail if correct space", config: { difficulty: "master" }, correct: true, - spaceOrNewline: true, - input: " ", + data: " ", + testInput: "hello", + targetWord: "hello ", + commitCharacterType: "separator", 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, + commitCharacterType, + expected, + }) => { + replaceConfig(config as any); + const result = checkIfFailedDueToDifficulty({ + data, + testInput, + targetWord, + correct, + commitCharacterType: commitCharacterType as + | "separator" + | "nospace" + | false, + }); + expect(result).toBe(expected); + }, + ); }); describe("checkIfFinished", () => { @@ -270,8 +339,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 +389,7 @@ describe("checkIfFinished", () => { allWordsTyped: true, testInputWithData: "wo ", currentWord: "word", - shouldGoToNextWord: true, + goingToNextWord: true, expected: true, }, { @@ -336,7 +403,7 @@ describe("checkIfFinished", () => { desc: string; allWordsTyped: boolean; allWordsGenerated?: boolean; - shouldGoToNextWord: boolean; + goingToNextWord: boolean; testInputWithData: string; currentWord: string; config?: Record; @@ -347,7 +414,7 @@ describe("checkIfFinished", () => { ({ allWordsTyped, allWordsGenerated, - shouldGoToNextWord, + goingToNextWord, testInputWithData, currentWord, config, @@ -356,7 +423,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..d1431b946b35 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,161 @@ 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 ", + commitCharacterType: 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 ", + commitCharacterType: "separator", }), - ).toBe(false); + ).toBe(true); }); + it("returns true when committing a word with a newline", () => { + expect( + shouldGoToNextWord({ + data: "\n", + inputValue: "word", + targetWord: "word\n", + commitCharacterType: "separator", + }), + ).toBe(true); + }); + + // the empty-input guard must not block a nospace commit on a 1-letter word, + // otherwise such words can never be advanced + it.each([ + { desc: "strictSpace on", strictSpace: true, difficulty: "normal" }, + { desc: "difficulty expert", strictSpace: false, difficulty: "expert" }, + ])( + "commits a nospace 1-letter word on empty input ($desc)", + ({ strictSpace, difficulty }) => { + replaceConfig({ strictSpace, difficulty } as any); + expect( + shouldGoToNextWord({ + data: "a", + inputValue: "", + targetWord: "a", + commitCharacterType: "nospace", + }), + ).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, + commitCharacterType: "separator", }), ).toBe(expected); }); diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index b059cd623b66..ca6b65ead622 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -79,6 +79,7 @@ import { __testing as statsTesting, getCorrectedWordsHistory, getKeypressSpacing, + getMissedWords, } from "../../../src/ts/test/events/stats"; import type { InputEventData, @@ -90,9 +91,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 { @@ -1022,48 +1033,42 @@ 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", () => { + pushWords("hello"); + expect(statsTesting.getTargetWord(buildEventLog(), 0)).toBe("hello"); }); - - 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("returns for out-of-range", () => { + expect(statsTesting.getTargetWord(buildEventLog(), 0)).toBe(undefined); }); + }); - it("appends trailing space for non-last word", () => { - pushWords("hello"); - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), - ).toBe("hello "); - }); + describe("getMissedWords", () => { + it("strips the commit separator but keeps a trailing tab", () => { + // word 0 is a code-mode-style word ending in a tab; pushWords appends the + // " " separator, so targetWords[0] is "foo\t " — the key must be "foo\t" + pushWords("foo\t", "bar"); - it("does not append trailing space for last word", () => { - pushWords("hello"); - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", true), - ).toBe("hello"); - }); + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent( + "input", + 1100, + input({ wordIndex: 0, data: "x", correct: false, charIndex: 0 }), + ); - 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"); + expect(getMissedWords(buildEventLog())).toEqual({ "foo\t": 1 }); }); - 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", false), - ).toBe("hello"); + it("strips a trailing space separator", () => { + pushWords("hello", "world"); + + logTestEvent("timer", 1000, timer("start", 0)); + logTestEvent( + "input", + 1100, + input({ wordIndex: 0, data: "x", correct: false, charIndex: 0 }), + ); + + expect(getMissedWords(buildEventLog())).toEqual({ hello: 1 }); }); }); @@ -1776,7 +1781,7 @@ describe("stats.ts", () => { expect(result[1]).toEqual("xy"); }); - it("ignores the space that commits a word", () => { + it("keeps the space that commits a word", () => { logTestEvent("timer", 1000, timer("start", 0)); logTestEvent( "input", @@ -1798,7 +1803,7 @@ describe("stats.ts", () => { 1250, input({ charIndex: 3, wordIndex: 0, data: "t" }), ); - // committing space — must not appear in the corrected word + // committing space — kept as the trailing separator of the corrected word logTestEvent( "input", 1300, @@ -1810,7 +1815,7 @@ describe("stats.ts", () => { }), ); - expect(getCorrectedWordsHistory(buildEventLog())).toEqual(["test"]); + expect(getCorrectedWordsHistory(buildEventLog())).toEqual(["test "]); }); }); }); diff --git a/frontend/__tests__/test/test-words.spec.ts b/frontend/__tests__/test/test-words.spec.ts new file mode 100644 index 000000000000..8c9a759f69d4 --- /dev/null +++ b/frontend/__tests__/test/test-words.spec.ts @@ -0,0 +1,64 @@ +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 added by the generator as a trailing space/newline; push + // splits that into the commit char while keeping the bare word as text + it("splits the trailing separator into the commit char", () => { + words.push("the ", 0); + words.push("cat ", 0); + words.push("sat", 0); + 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", + ]); + }); + + it("tracks length and section indexes", () => { + words.push("a ", 3); + words.push("b", 5); + expect(words.length).toBe(2); + expect(words.get().map((w) => w.sectionIndex)).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.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.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.get().map((w) => w.textWithCommit)).toEqual(["the ", "end"]); + }); + + it("does nothing on an empty list", () => { + expect(() => words.removeCommitCharacterFromLastWord()).not.toThrow(); + expect(words.get()).toEqual([]); + }); + }); +}); diff --git a/frontend/__tests__/utils/strings.spec.ts b/frontend/__tests__/utils/strings.spec.ts index bd3ec258720f..01bcd6f441b1 100644 --- a/frontend/__tests__/utils/strings.spec.ts +++ b/frontend/__tests__/utils/strings.spec.ts @@ -542,6 +542,15 @@ describe("string utils", () => { expect(Strings.areCharactersVisuallyEqual(",", '"')).toBe(false); }); + it("should treat any Unicode space as equivalent to a regular space", () => { + // IME-produced spaces must match the U+0020 stored as the word separator + expect(Strings.areCharactersVisuallyEqual(" ", " ")).toBe(true); // ideographic + expect(Strings.areCharactersVisuallyEqual(" ", " ")).toBe(true); // nbsp + expect(Strings.areCharactersVisuallyEqual(" ", " ")).toBe(true); // en space + expect(Strings.areCharactersVisuallyEqual(" ", "a")).toBe(false); + expect(Strings.areCharactersVisuallyEqual(" ", "\n")).toBe(false); + }); + describe("should check russian specific equivalences", () => { it.each([ { diff --git a/frontend/src/ts/commandline/lists/result-screen.ts b/frontend/src/ts/commandline/lists/result-screen.ts index b07bc245e8bd..d629b69ffaf0 100644 --- a/frontend/src/ts/commandline/lists/result-screen.ts +++ b/frontend/src/ts/commandline/lists/result-screen.ts @@ -151,8 +151,8 @@ const commands: Command[] = [ : TestWords.words .get() .slice(0, inputHistory.length) - .map((word) => word.text) - .join(" "); + .map((word) => word.textWithCommit) + .join(""); navigator.clipboard.writeText(words).then( () => { diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index 8d54c3f3d4dc..f923591d7e5d 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -1,11 +1,11 @@ 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 { 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"); diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 63bd9be0beb6..efa35660f2b2 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -1,15 +1,16 @@ 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 { 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 { getCommitCharacterType } from "../helpers/util"; +import { getCurrentInput } from "../../test/events/data"; +import { isSpace } from "../../utils/strings"; /** * Handles logic before inserting text into the input element. @@ -29,20 +30,29 @@ export function onBeforeInsertText(data: string): boolean { return true; } + //only allow newlines if the test has newlines or in zen mode + if (data === "\n" && !wordsHaveNewline() && Config.mode !== "zen") { + return true; + } + + //prevent space in nospace funbox + if (isSpace(data) && isFunboxActiveWithProperty("nospace")) { + return true; + } + const { inputValue } = getInputElementValue(); const currentWordTextWithCommit = TestWords.words.getCurrent()?.textWithCommit ?? ""; - const dataIsSpace = isSpace(data); - const shouldInsertSpaceAsCharacter = shouldInsertSpaceCharacter({ + const commitCharacterType = getCommitCharacterType({ data, inputValue, targetWord: currentWordTextWithCommit, }); - //prevent space from being inserted if input is empty + //prevent separator from being inserted if input is empty //allow if strict space is enabled if ( - dataIsSpace && + commitCharacterType === "separator" && inputValue === "" && Config.difficulty === "normal" && !Config.strictSpace @@ -50,21 +60,18 @@ export function onBeforeInsertText(data: string): boolean { return true; } - //prevent space in nospace funbox - if (dataIsSpace && isFunboxActiveWithProperty("nospace")) { - return true; - } - - //only allow newlines if the test has newlines or in zen mode - if (data === "\n" && !wordsHaveNewline() && Config.mode !== "zen") { - return true; - } - // block input if the word is too long const inputLimit = Config.mode === "zen" ? 30 : currentWordTextWithCommit.length + 20; - const overLimit = getCurrentInput().length >= inputLimit; - if (overLimit && (shouldInsertSpaceAsCharacter === true || !dataIsSpace)) { + const overLimit = inputValue.length >= inputLimit; + const goingToNextWord = shouldGoToNextWord({ + data, + inputValue, + targetWord: currentWordTextWithCommit, + commitCharacterType, + }); + + if (overLimit && !goingToNextWord) { console.error("Hitting word limit"); return true; } @@ -81,7 +88,7 @@ export function onBeforeInsertText(data: string): boolean { !Config.blindMode && !Config.hideExtraLetters && inputIsLongerThanOrEqualToWord && - (shouldInsertSpaceAsCharacter === true || !dataIsSpace) && + !goingToNextWord && Config.mode !== "zen" ) { // make sure to only check this when really necessary @@ -93,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 14717b4daa58..4adecbf85512 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 03289668628b..d095adf910f3 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -13,12 +13,10 @@ import { } from "../helpers/fail-or-finish"; import { areCharactersVisuallyEqual, - isSpace, removeLanguageSize, } 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"; @@ -32,12 +30,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 { getCommitCharacterType } from "../helpers/util"; +import { areAllWordsGenerated } from "../../test/words-generator"; const charOverrides = new Map([ ["…", "..."], @@ -147,45 +143,22 @@ 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 commitCharacterType = getCommitCharacterType({ + data, + inputValue: testInput, + targetWord: currentWord, + }); // is char correct - const charCorrect = isCharCorrect({ + const correct = isCharCorrect({ data, inputValue: testInput, targetWord: currentWord, correctShiftUsed, }); - // word navigation check - const noSpaceForce = - isFunboxActiveWithProperty("nospace") && - (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; - - // 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 // like accuracy, keypress errors, and missed words @@ -198,7 +171,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { removeLastChar = true; } - if (!charIsSpace && correctShiftUsed === false) { + if (correctShiftUsed === false) { removeLastChar = true; visualInputOverride = undefined; incrementIncorrectShiftsInARow(); @@ -212,8 +185,15 @@ 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; + // derived after removeLastChar: stop-on-error and opposite shift mode can block navigation + const goingToNextWord = + !removeLastChar && + shouldGoToNextWord({ + data, + inputValue: testInput, + targetWord: currentWord, + commitCharacterType, + }); if (Config.keymapMode === "react") { flash(data, correct); @@ -240,41 +220,30 @@ 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); - const commitCorrect = noSpaceForce - ? testInput + data === currentWord - : correct; + if (lastInMultiOrSingle) { + TestUI.afterTestTextInput(correct, visualInputOverride); + } // 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", + correctInsert: + Config.mode === "zen" ? true : testInput + data === currentWord, now, }); lastBurst = result.lastBurst; increasedWordIndex = result.increasedWordIndex; } - /* - 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 - */ - //this COULD be the next word because we are awaiting goToNextWord const nextWord = TestWords.words.getCurrent()?.textWithCommit ?? ""; const doesNextWordHaveTab = /^\t+/.test(nextWord); @@ -295,9 +264,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, + commitCharacterType, }) ) { TestLogic.fail("difficulty"); @@ -312,20 +283,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..0257af723324 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 { type CommitCharacterType } from "./util"; /** * Check if the test should fail due to minimum burst settings @@ -36,16 +37,20 @@ export function checkIfFailedDueToMinBurst(options: { /** * Check if the test should fail due to difficulty settings * @param options - Options object - * @param options.testInputWithData - Current test input result (after adding data) - * @param options.correct - Was the last input correct - * @param options.spaceOrNewline - Is the input a space or newline + * @param options.data - The text data to be inserted + * @param options.testInput - Current test input result (before adding data) + * @param options.targetWord - Current target word + * @param options.correct - Whether the input is correct + * @param options.commitCharacterType - Type of the commit character, false if not a commit character */ export function checkIfFailedDueToDifficulty(options: { - testInputWithData: string; + data: string; + testInput: string; + targetWord: string; correct: boolean; - spaceOrNewline: boolean; + commitCharacterType: CommitCharacterType | false; }): boolean { - const { testInputWithData, correct, spaceOrNewline } = options; + const { data, testInput, targetWord, correct, commitCharacterType } = 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 +58,11 @@ export function checkIfFailedDueToDifficulty(options: { const shouldFailDueToExpert = Config.difficulty === "expert" && - !correct && - spaceOrNewline && - testInputWithData.length > 1; + commitCharacterType !== false && + // a leading separator (empty input) commits nothing and must not fail; + // a nospace commit (e.g. a 1-letter word) does commit on empty input + !(commitCharacterType === "separator" && testInput.length === 0) && + testInput + data !== targetWord; const shouldFailDueToMaster = Config.difficulty === "master" && !correct; @@ -68,21 +75,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 +103,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..6596cf1e9e24 --- /dev/null +++ b/frontend/src/ts/input/helpers/util.ts @@ -0,0 +1,33 @@ +import { isFunboxActiveWithProperty } from "../../test/funbox/list"; +import { isSpace } from "../../utils/strings"; + +/** + * What kind of commit a character triggers, or false if it does not commit. + * - "separator": a space or newline that ends the current word + * - "nospace": the final letter of a word in a nospace funbox + */ +export type CommitCharacterType = "separator" | "nospace"; + +export function getCommitCharacterType(options: { + data: string; + inputValue: string; + targetWord: string; +}): CommitCharacterType | false { + const { data, inputValue, targetWord } = options; + + if (isSpace(data)) { + return "separator"; + } + + if (data === "\n") { + return "separator"; + } + + const nospace = isFunboxActiveWithProperty("nospace"); + + if (nospace && (inputValue + data).length === targetWord.length) { + return "nospace"; + } + + return false; +} diff --git a/frontend/src/ts/input/helpers/validation.ts b/frontend/src/ts/input/helpers/validation.ts index a2712335b3b2..dad7e271a1be 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 { type CommitCharacterType } from "./util"; /** * Check if the input data is correct @@ -30,57 +30,51 @@ 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 + * @param options.commitCharacterType - Type of the commit character, false if not a commit character + * @returns Whether to move to the next word */ -export function isWordCorrect(options: { +export function shouldGoToNextWord(options: { data: string; inputValue: string; targetWord: string; - correctShiftUsed: boolean | null; //null means disabled + commitCharacterType: CommitCharacterType | false; }): boolean { - const { data, inputValue, targetWord, correctShiftUsed } = options; + const { + inputValue, + targetWord, + data, + commitCharacterType: commitType, + } = options; + + if (commitType === false) return false; if (Config.mode === "zen") return true; - if (correctShiftUsed === false) return false; - const finalInputValue = inputValue + (isSpace(data) ? "" : data); - return finalInputValue === targetWord; -} + //strict space: a leading separator on empty input must not skip the word. + //nospace commits (final letter of a 1-letter word) are legitimate here. + if ( + inputValue.length === 0 && + commitType === "separator" && + (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 cad34a02e7ab..ceb79d63c990 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(); @@ -88,16 +79,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(); @@ -111,8 +99,11 @@ export function goToPreviousWord( } else if (inputType === "deleteContentBackward") { const word = getInputForWord(TestState.activeWordIndex); if (nospaceEnabled) { + // nospace has no separator, so the prior word's commit was its last + // letter; a single backspace deletes that letter (same as non-nospace + // deletes the separator below) 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/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index 02948c590545..498377d70312 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -13,8 +13,8 @@ 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 { areAllWordsGenerated } from "../../test/words-generator"; const inputEl = getInputElement(); @@ -136,7 +136,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/events/data.ts b/frontend/src/ts/test/events/data.ts index 03d7a81c8628..079888c75527 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..a62422c57d61 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -345,35 +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 ""; - } - - if (word.endsWith("\n")) { - // for multiline, dont add space - return word; - } - - let wordEnd = ""; - - if (!lastWord) { - wordEnd = " "; - } - - if (eventLog.context.isFunboxWithNospacePropertyActive) { - wordEnd = ""; - } - - return word + wordEnd; - } +): string | undefined { + return eventLog.context.targetWords[wordIndex]; } function computeBurst(events: TestEventNoMs[], now?: number): number { @@ -463,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(""); } @@ -897,7 +870,11 @@ export function getMissedWords(eventLog: EventLog): Record { ) { const word = eventLog.context.targetWords[event.data.wordIndex]; if (word === undefined) continue; - missedWords[word] = (missedWords[word] ?? 0) + 1; + // targetWords store the trailing separator (commit char); strip exactly + // that one separator (space/newline) to key by the bare word — not + // trimEnd(), which would also eat a meaningful trailing tab (code mode) + const bareWord = word.replace(/[ \n]$/, ""); + missedWords[bareWord] = (missedWords[bareWord] ?? 0) + 1; } } @@ -919,10 +896,7 @@ export function getCorrectedWordsHistory(eventLog: EventLog): string[] { event.data.inputType === "insertText" || event.data.inputType === "insertCompositionText" ) { - if ( - event.data.inputStopped || - (event.data.data === " " && event.data.commitsWord) - ) { + if (event.data.inputStopped) { continue; } currentChars[cursorPos] = event.data.data; diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index f6047268d80d..31a950a29920 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -177,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++; @@ -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.get(settings.currentWordIndex)!.display.length + TestWords.words.get(settings.currentWordIndex)!.text.length + 1 ) { //go to the next word settings.currentLetterIndex = 0; @@ -234,7 +234,7 @@ export function handleSpace(correct: boolean, currentWord: string): void { !Config.blindMode ) { settings.wordsStatus[TestState.activeWordIndex] = undefined; - settings.correction -= currentWord.length + 1; + settings.correction -= currentWord.length; } } else { if ( @@ -243,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/replay-ui.ts b/frontend/src/ts/test/replay-ui.ts index e1efe68bcb0f..cffdc7a77974 100644 --- a/frontend/src/ts/test/replay-ui.ts +++ b/frontend/src/ts/test/replay-ui.ts @@ -44,7 +44,7 @@ function getWordsList(): string[] { return TestWords.words .get() .slice() - .map((word) => word.text); + .map((word) => word.textWithCommit); } function deriveReplayActions(): Replay[] { @@ -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", diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index d88ecd693ef7..8444ad7d3a3e 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( 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.get()); 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; } @@ -706,8 +691,11 @@ export async function addWord(): Promise { break; } wordCount++; - TestWords.words.push(word, i); - TestUI.addWord(word); + const newWord = TestWords.words.push( + WordsGenerator.appendCommitCharacter(word), + i, + ); + TestUI.addWord(newWord.display); } } } @@ -720,8 +708,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( @@ -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,8 +1076,13 @@ export async function finish(difficultyFailed = false): Promise { const lastWordInputLength = history[wordIndex]?.length ?? 0; + // compare against display.length (not textWithCommit.length): the input + // history holds the typed letters, not the committing space separator, so + // a space word is "complete" at text.length. display includes a newline + // commit, which is a required typed char. if ( - lastWordInputLength < (TestWords.words.get(wordIndex)?.text.length ?? 0) + lastWordInputLength < + (TestWords.words.get(wordIndex)?.display.length ?? 0) ) { historyLength--; } diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index cbfed884ea3f..6f991a7f1be2 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 { @@ -806,14 +803,24 @@ export async function updateWordLetters({ } ret += `${letter}`; } else { - ret += `${ + let charString = currentLetter; + + if ( Config.indicateTypos === "replace" || Config.indicateTypos === "both" - ? inputChars[i] === " " || inputChars[i] === "\t" - ? "_" - : inputChars[i] - : currentLetter - }`; + ) { + charString = inputChars[i] ?? currentLetter; + + if (charString === " ") { + charString = "_"; + } else if (charString === "\t") { + charString = "_"; + } else if (charString === "\n") { + charString = ""; + } + } + + ret += `${charString}`; if ( Config.indicateTypos === "below" || Config.indicateTypos === "both" @@ -1254,47 +1261,47 @@ export function setJoiningClass(isEnabled: boolean): void { } function buildWordLettersHTML( - charCount: number, input: string, corrected: string, - inputCharacters: string[], - wordCharacters: string[], - correctedCharacters: string[], - containsKorean: boolean, + targetWord: string, ): string { let out = ""; - for (let c = 0; c < charCount; c++) { - let correctedChar; - try { - correctedChar = !containsKorean - ? correctedCharacters[c] - : Hangul.assemble(corrected.split(""))[c]; - } catch (e) { - correctedChar = undefined; + for ( + let c = 0; + c < Math.max(targetWord.length, input.length, corrected.length); + c++ + ) { + let inputChar = Strings.splitIntoCharacters(input)[c]; + let targetChar = Strings.splitIntoCharacters(targetWord)[c]; + + if ( + (targetChar === " " || targetChar === "\n") && + inputChar !== undefined + ) { + continue; } + + let correctedChar = Strings.splitIntoCharacters(corrected)[c]; let extraCorrected = ""; - const historyWord: string = !containsKorean + const historyWord: string = !TestState.koreanStatus ? corrected : Hangul.assemble(corrected.split("")); if ( - c + 1 === charCount && + c + 1 === Math.max(input.length, corrected.length) && historyWord !== undefined && historyWord.length > input.length ) { extraCorrected = "extraCorrected"; } - let displayLetter = inputCharacters[c]; + let displayLetter = inputChar; if (displayLetter === " ") { displayLetter = "_"; } - if (Config.mode === "zen" || wordCharacters[c] !== undefined) { - if (Config.mode === "zen" || inputCharacters[c] === wordCharacters[c]) { - if ( - correctedChar === inputCharacters[c] || - correctedChar === undefined - ) { + if (Config.mode === "zen" || targetChar !== undefined) { + if (Config.mode === "zen" || inputChar === targetChar) { + if (correctedChar === inputChar || correctedChar === undefined) { out += `${displayLetter}`; } else { out += `${ @@ -1302,15 +1309,15 @@ function buildWordLettersHTML( }`; } } else { - if (inputCharacters[c] === getCurrentInput()) { + if (inputChar === getCurrentInput()) { out += `${ - wordCharacters[c] + targetChar }`; - } else if (inputCharacters[c] === undefined) { - out += `${wordCharacters[c]}`; + } else if (inputChar === undefined) { + out += `${targetChar}`; } else { out += `${ - wordCharacters[c] + targetChar }`; } } @@ -1329,20 +1336,17 @@ async function loadWordsHistory(): Promise { return false; } - const inputHistory = getInputHistory(TestState.lastEventLog).map((i) => - i.trimEnd(), - ); + const inputHistory = getInputHistory(TestState.lastEventLog); const burstHistory = getWordBurstHistory(TestState.lastEventLog); + const correctedHistory = getCorrectedWordsHistory(TestState.lastEventLog); const inputHistoryLength = inputHistory.length; for (let i = 0; i < inputHistoryLength + 2; i++) { const input = inputHistory[i]; - const corrected = correctedHistory[i]; - const word = TestWords.words.get(i)?.text ?? ""; - const koreanRegex = - /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/; - const containsKorean = - koreanRegex.test(input ?? "") || koreanRegex.test(word); + const target = TestWords.words.get(i)?.textWithCommit ?? ""; + const corrected = TestState.koreanStatus + ? Hangul.assemble((correctedHistory[i] ?? "").split("")) + : correctedHistory[i]; const wordEl = document.createElement("div"); wordEl.className = "word"; @@ -1356,13 +1360,13 @@ async function loadWordsHistory(): Promise { throw new Error("empty input word"); } - const isIncorrectWord = input !== word; + const isIncorrectWord = input !== target; const isLastWord = i === inputHistoryLength - 1; const isTimedTest = Config.mode === "time" || (Config.mode === "custom" && CustomText.getLimitMode() === "time") || (Config.mode === "custom" && CustomText.getLimitValue() === 0); - const isPartiallyCorrect = word.startsWith(input); + const isPartiallyCorrect = target.startsWith(input); const shouldShowError = Config.mode !== "zen" && @@ -1377,42 +1381,24 @@ async function loadWordsHistory(): Promise { wordEl.setAttribute("burst", String(burstValue)); } + let inputAttribute = input; + if (corrected !== undefined && corrected !== "") { - const correctedChar = !containsKorean - ? corrected - : Hangul.assemble(corrected.split("")); - wordEl.setAttribute("input", correctedChar.replace(/ /g, "_")); - } else { - wordEl.setAttribute("input", input.replace(/ /g, "_")); + inputAttribute = corrected; } - const inputCharacters = Strings.splitIntoCharacters(input); - const wordCharacters = Strings.splitIntoCharacters(word); - const correctedCharacters = Strings.splitIntoCharacters(corrected ?? ""); - - let loop; - if (Config.mode === "zen" || input.length > word.length) { - //input is longer - extra characters possible (loop over input) - loop = inputCharacters.length; - } else { - //input is shorter or equal (loop over word list) - loop = wordCharacters.length; + if (inputAttribute.length >= target.length) { + inputAttribute = inputAttribute.trimEnd(); } + wordEl.setAttribute("input", inputAttribute); + if (corrected === undefined) throw new Error("empty corrected word"); - wordEl.innerHTML = buildWordLettersHTML( - loop, - input, - corrected, - inputCharacters, - wordCharacters, - correctedCharacters, - containsKorean, - ); + wordEl.innerHTML = buildWordLettersHTML(input, corrected, target); } catch (e) { try { - for (let char of word) { + for (let char of target) { if (char === " ") { char = "_"; } @@ -1470,9 +1456,10 @@ export async function toggleResultWords(noAnimation = false): Promise { ResultWordHighlight.updateToggleWordsHistoryTime(); if (resultWordsHistoryEl.isHidden()) { - if (resultWordsHistoryEl.qsa(".words .word").length === 0) { - await loadWordsHistory(); - } + // if (resultWordsHistoryEl.qsa(".words .word").length === 0) { + resultWordsHistoryEl.qsa(".words .word")?.remove(); + await loadWordsHistory(); + // } void resultWordsHistoryEl.slideDown(noAnimation ? 0 : 250); void applyBurstHeatmap(); } else { @@ -1784,21 +1771,15 @@ function afterAnyTestInput( export function afterTestTextInput( correct: boolean, - increasedWordIndex: boolean | null, inputOverride?: string, ): void { - //nospace cant be handled here becauseword index - // is already increased at this point - 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); } @@ -1825,24 +1806,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, ): void { - const nospaceEnabled = isFunboxActiveWithProperty("nospace"); - if ( - (Config.stopOnError === "letter" && (correct || correct === null)) || - nospaceEnabled || - forceUpdateActiveWordLetters - ) { + if (direction === "back") { void updateWordLetters({ input: getCurrentInputForDisplay(), wordIndex: TestState.activeWordIndex, @@ -1971,8 +1941,8 @@ qs(".pageTest #copyWordsListButton")?.on("click", async () => { words = TestWords.words .get() .slice(0, getInputHistory(TestState.lastEventLog).length) - .map((w) => w.text) - .join(" "); + .map((w) => w.textWithCommit) + .join(""); } await copyToClipboard(words); }); diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index aef329b405bc..535b2bc765ea 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,20 +58,34 @@ 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 { this.list = []; this.length = 0; } + + removeCommitCharacterFromLastWord(): void { + if (this.length === 0) return; + const lastWord = this.list[this.length - 1]; + if (lastWord === undefined) return; + if (lastWord.commit === " " || lastWord.commit === "\n") { + lastWord.commit = ""; + lastWord.textWithCommit = lastWord.text; + lastWord.display = lastWord.text; + } + } } export const words = new Words(); diff --git a/frontend/src/ts/test/words-generator.ts b/frontend/src/ts/test/words-generator.ts index 52a3c9c1e4d2..cd51f7f6f800 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; @@ -969,6 +971,8 @@ export async function getNextWord( console.debug("Word:", randomWord); + randomWord = appendCommitCharacter(randomWord); + const ret = { word: randomWord, sectionIndex: sectionIndex, @@ -978,3 +982,35 @@ 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" && + 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..ebe1103b674b 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -320,6 +320,13 @@ export function areCharactersVisuallyEqual( return true; } + // Treat any Unicode space as equivalent to the regular U+0020 separator. + // This lets IME-produced spaces (e.g. U+3000) match stored word separators. + // The U+0020 guard short-circuits the common non-space case before calling isSpace. + if ((char1 === " " || char2 === " ") && isSpace(char1) && isSpace(char2)) { + return true; + } + // Check each equivalence map for (const map of CHAR_EQUIVALENCE_SETS) { if (map.has(char1) && map.has(char2)) { @@ -357,6 +364,25 @@ export function toHex(buffer: ArrayBuffer): string { return hashHex; } +// hoisted to module scope so isSpace doesn't allocate a Set on every call +// (it runs per keystroke via areCharactersVisuallyEqual) +const SPACE_CODE_POINTS = new Set([ + 0x0020, // Regular space (spacebar) + 0x2002, // En space (Option+Space on Mac) + 0x2003, // Em space (Option+Shift+Space on Mac) + 0x2009, // Thin space (various input methods) + 0x3000, // Ideographic space (CJK input methods) + 0x00a0, // Non-breaking space (Alt+0160 on Windows, Option+Space on Mac) + 0x1680, // Ogham space mark (rare, but included for completeness) + 0x202f, // Narrow no-break space (various input methods) + 0xfeff, // Zero width no-break space (various input methods) + 0x2007, // Figure space (various input methods) + 0x2008, // Punctuation space (various input methods) + 0x2004, // Three-per-em space (various input methods) + 0x200a, // Hair space (various input methods) + 0x200b, // Zero width space (various input methods) +]); + /** * 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. @@ -369,24 +395,7 @@ export function isSpace(char: string): boolean { const codePoint = char.codePointAt(0); if (codePoint === undefined) return false; - const spaces = new Set([ - 0x0020, // Regular space (spacebar) - 0x2002, // En space (Option+Space on Mac) - 0x2003, // Em space (Option+Shift+Space on Mac) - 0x2009, // Thin space (various input methods) - 0x3000, // Ideographic space (CJK input methods) - 0x00a0, // Non-breaking space (Alt+0160 on Windows, Option+Space on Mac) - 0x1680, // Ogham space mark (rare, but included for completeness) - 0x202f, // Narrow no-break space (various input methods) - 0xfeff, // Zero width no-break space (various input methods) - 0x2007, // Figure space (various input methods) - 0x2008, // Punctuation space (various input methods) - 0x2004, // Three-per-em space (various input methods) - 0x200a, // Hair space (various input methods) - 0x200b, // Zero width space (various input methods) - ]); - - return spaces.has(codePoint); + return SPACE_CODE_POINTS.has(codePoint); } export function replaceUnderscoresWithSpaces(text: string): string {