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 b059cd623b66..133268544944 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -90,9 +90,19 @@ import { Config } from "../../../src/ts/config/store"; import { Keycode } from "../../../src/ts/constants/keys"; import * as TestState from "../../../src/ts/test/test-state"; import { words as TestWords } from "../../../src/ts/test/test-words"; +import { isFunboxActiveWithProperty } from "../../../src/ts/test/funbox/list"; +// mirror the generator: each word carries a trailing space separator unless it +// already ends with a newline, the nospace funbox is active, or it's the last +// word (the final separator is stripped once all words are generated) function pushWords(...words: string[]): void { - words.forEach((word, i) => TestWords.push(word, i)); + const nospace = isFunboxActiveWithProperty("nospace"); + words.forEach((word, i) => { + const isLast = i === words.length - 1; + const withSeparator = + isLast || nospace || word.endsWith("\n") ? word : `${word} `; + TestWords.push(withSeparator, i); + }); } function keyDown(code: Keycode = "KeyA"): KeydownEventData { @@ -1022,48 +1032,12 @@ describe("stats.ts", () => { }); describe("getTargetWord", () => { - it("returns simulatedInput in zen mode", () => { - (Config as { mode: string }).mode = "zen"; - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "anything", false), - ).toBe("anything"); - }); - - it("returns word without trailing space when it ends with newline", () => { - pushWords("hello\n"); - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), - ).toBe("hello\n"); - }); - - it("appends trailing space for non-last word", () => { + it("returns word", () => { pushWords("hello"); - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), - ).toBe("hello "); + expect(statsTesting.getTargetWord(buildEventLog(), 0)).toBe("hello"); }); - - it("does not append trailing space for last word", () => { - pushWords("hello"); - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", true), - ).toBe("hello"); - }); - - it("does not append trailing space when nospace funbox is active", () => { - pushWords("hello"); - (Config as { funbox: string[] }).funbox = ["nospace"]; - expect( - statsTesting.getTargetWord(buildEventLog(), 0, "hello", false), - ).toBe("hello"); - }); - - 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("returns for out-of-range", () => { + expect(statsTesting.getTargetWord(buildEventLog(), 0)).toBe(undefined); }); }); diff --git a/frontend/__tests__/test/test-words.spec.ts b/frontend/__tests__/test/test-words.spec.ts 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/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..00d21757150d 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -1,5 +1,4 @@ import { Config } from "../../config/store"; -import { getCurrentInput } from "../../test/events/data"; import * as TestState from "../../test/test-state"; import * as TestUI from "../../test/test-ui"; import * as TestWords from "../../test/test-words"; @@ -7,9 +6,11 @@ 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 { isCommitCharacter } from "../helpers/util"; +import { getCurrentInput } from "../../test/events/data"; /** * Handles logic before inserting text into the input element. @@ -33,11 +34,6 @@ export function onBeforeInsertText(data: string): boolean { const currentWordTextWithCommit = TestWords.words.getCurrent()?.textWithCommit ?? ""; const dataIsSpace = isSpace(data); - const shouldInsertSpaceAsCharacter = shouldInsertSpaceCharacter({ - data, - inputValue, - targetWord: currentWordTextWithCommit, - }); //prevent space from being inserted if input is empty //allow if strict space is enabled @@ -63,8 +59,22 @@ export function onBeforeInsertText(data: string): boolean { // 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 targetWord = currentWordTextWithCommit; + const isCommit = isCommitCharacter({ + data, + inputValue, + targetWord, + }); + if ( + overLimit && + !shouldGoToNextWord({ + data, + inputValue, + targetWord, + isCommitCharacter: isCommit, + }) + ) { console.error("Hitting word limit"); return true; } @@ -81,7 +91,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 @@ -93,7 +103,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..5dd57ea2eeed 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 { isCommitCharacter } 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 isCommit = isCommitCharacter({ + 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, + isCommitCharacter: isCommit, + }); if (Config.keymapMode === "react") { flash(data, correct); @@ -240,25 +220,24 @@ 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; @@ -266,13 +245,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 @@ -295,9 +274,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"); @@ -312,20 +293,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 cad34a02e7ab..d3c446cb35cd 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -18,9 +18,6 @@ import { buildEventLog, getInputForWord } from "../../test/events/data"; type GoToNextWordParams = { correctInsert: boolean; - // this is used to tell test ui to update the word before moving to the next word (in case of a composition that ends with a space) - isCompositionEnding: boolean; - zenNewline?: boolean; now: number; }; @@ -31,8 +28,6 @@ type GoToNextWordReturn = { export async function goToNextWord({ correctInsert, - isCompositionEnding, - zenNewline, now, }: GoToNextWordParams): Promise { const ret: GoToNextWordReturn = { @@ -40,11 +35,7 @@ export async function goToNextWord({ lastBurst: null, }; - TestUI.beforeTestWordChange( - "forward", - correctInsert, - isCompositionEnding || zenNewline === true, - ); + TestUI.beforeTestWordChange("forward", correctInsert); for (const fb of getActiveFunboxesWithFunction("handleSpace")) { fb.functions.handleSpace(); @@ -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(); @@ -112,7 +100,7 @@ export function goToPreviousWord( const word = getInputForWord(TestState.activeWordIndex); if (nospaceEnabled) { setInputElementValue(word.slice(0, -1)); - } else if (word.endsWith("\n")) { + } else if (word.endsWith("\n") || word.endsWith(" ")) { setInputElementValue(word.slice(0, -1)); } else { setInputElementValue(word); diff --git a/frontend/src/ts/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..cf67199765e7 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,9 @@ 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); key by the bare word + const bareWord = word.trimEnd(); + missedWords[bareWord] = (missedWords[bareWord] ?? 0) + 1; } } diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 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..dd3fad611f52 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 = { @@ -1080,7 +1077,8 @@ export async function finish(difficultyFailed = false): Promise { const lastWordInputLength = history[wordIndex]?.length ?? 0; 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..6179a47a94cd 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 { @@ -1784,7 +1781,6 @@ function afterAnyTestInput( export function afterTestTextInput( correct: boolean, - increasedWordIndex: boolean | null, inputOverride?: string, ): void { //nospace cant be handled here becauseword index @@ -1792,13 +1788,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); } @@ -1825,24 +1819,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, diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index aef329b405bc..10e6631bdd89 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,37 @@ 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.textWithCommit.endsWith(" ") || + lastWord.textWithCommit.endsWith("\n") + ) { + lastWord.textWithCommit = lastWord.textWithCommit.slice(0, -1); + lastWord.display = lastWord.textWithCommit; + lastWord.commit = ""; + } + } } 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) + ); +}