diff --git a/AGENTS.md b/AGENTS.md index 58878f9e498c..1e4e0a33e6d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,8 @@ Be extremely concise. Sacrifice grammar for concision. Frontend is partially migrated from vanilla JS to SolidJS — new components use `.tsx`, legacy code remains vanilla. Single test file: `pnpm vitest run path/to/test.ts` +When running oxc lint, always use `--format agent`. +For typechecking, use `pnpm oxlint --type-aware --type-check` instead of `tsc`. For styling, use Tailwind CSS, class property, `cn` utility. Do not use classlist. Only colors available are those defined in Tailwind config. In legacy code, use `i` tags with FontAwesome classes. In new code, use `Fa` component. -In plan mode, before writing up a plan, ask clarifying questions if needed. At the end of plan mode, give me a list of unresolved questions to answer, if any. Make them concise. +In plan mode, before writing up a plan, ask clarifying questions if needed. At the end of plan mode, give me a list of unresolved questions to answer, if any. Make them concise. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 4bb483a53281..1e4e0a33e6d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Be extremely concise. Sacrifice grammar for concision. Frontend is partially migrated from vanilla JS to SolidJS — new components use `.tsx`, legacy code remains vanilla. Single test file: `pnpm vitest run path/to/test.ts` When running oxc lint, always use `--format agent`. -For typechecking, use `oxc --type-aware --type-check` instead of `tsc`. +For typechecking, use `pnpm oxlint --type-aware --type-check` instead of `tsc`. For styling, use Tailwind CSS, class property, `cn` utility. Do not use classlist. Only colors available are those defined in Tailwind config. In legacy code, use `i` tags with FontAwesome classes. In new code, use `Fa` component. In plan mode, before writing up a plan, ask clarifying questions if needed. At the end of plan mode, give me a list of unresolved questions to answer, if any. Make them concise. \ No newline at end of file diff --git a/frontend/__tests__/test/events/helpers.spec.ts b/frontend/__tests__/test/events/helpers.spec.ts index 70c8c98e5659..c63b41482add 100644 --- a/frontend/__tests__/test/events/helpers.spec.ts +++ b/frontend/__tests__/test/events/helpers.spec.ts @@ -8,7 +8,6 @@ vi.mock("../../../src/ts/config/store", () => ({ import { findInputValueMismatches, getInputFromDom, - getInputFromEvents, getTestEventCode, } from "../../../src/ts/test/events/helpers"; import type { InputEvent } from "../../../src/ts/test/events/types"; @@ -17,6 +16,7 @@ import type { InsertInputType } from "../../../src/ts/input/helpers/input-type"; let nextMs = 0; let charIndex = 0; let wordIndex = 0; +let currentInput = ""; function insert( chars: string, @@ -25,6 +25,9 @@ function insert( ): InputEvent[] { return [...chars].map((char) => { nextMs += 10; + if (!overrides.inputStopped) { + currentInput += char; + } const event: InputEvent = { type: "input", ms: nextMs, @@ -35,6 +38,7 @@ function insert( inputType, data: char, correct: true, + inputValue: currentInput, ...overrides, }, }; @@ -52,6 +56,7 @@ function insert( function deleteBackward(count = 1): InputEvent[] { return Array.from({ length: count }, () => { nextMs += 10; + currentInput = currentInput.slice(0, -1); const event: InputEvent = { type: "input", ms: nextMs, @@ -60,6 +65,7 @@ function deleteBackward(count = 1): InputEvent[] { charIndex, wordIndex, inputType: "deleteContentBackward", + inputValue: currentInput, }, }; if (charIndex > 0) charIndex--; @@ -70,6 +76,7 @@ function deleteBackward(count = 1): InputEvent[] { function deleteWordBackward(): InputEvent { nextMs += 10; charIndex = 0; + currentInput = currentInput.replace(/(?:\S+\s*|\s+)$/, ""); const event = { type: "input", ms: nextMs, @@ -78,6 +85,7 @@ function deleteWordBackward(): InputEvent { charIndex, wordIndex, inputType: "deleteWordBackward", + inputValue: currentInput, }, } as const; if (wordIndex > 0) wordIndex--; @@ -89,69 +97,61 @@ function reset(): void { nextMs = 0; charIndex = 0; wordIndex = 0; + currentInput = ""; } -describe("getInputFromEvents", () => { +describe("getInputFromDom", () => { beforeEach(() => { reset(); }); - it("builds string from insertText events", () => { - expect(getInputFromEvents([...insert("hello")])).toBe("hello"); + it("returns the last event's inputValue", () => { + expect(getInputFromDom([...insert("hello")])).toBe("hello"); }); - it("builds string from insertText events with trailing space", () => { - expect(getInputFromEvents([...insert("hello ")])).toBe("hello "); + it("returns inputValue with trailing space", () => { + expect(getInputFromDom([...insert("hello ")])).toBe("hello "); }); - it("handles deleteContentBackward", () => { - expect(getInputFromEvents([...insert("abc"), ...deleteBackward()])).toBe( - "ab", - ); + it("returns inputValue after deleteContentBackward", () => { + expect(getInputFromDom([...insert("abc"), ...deleteBackward()])).toBe("ab"); }); - it("handles deleteContentBackward after space", () => { - expect(getInputFromEvents([...insert("abc "), ...deleteBackward()])).toBe( + it("returns inputValue after deleteContentBackward across space", () => { + expect(getInputFromDom([...insert("abc "), ...deleteBackward()])).toBe( "abc", ); }); - it("handles multiple deletes", () => { - expect(getInputFromEvents([...insert("ab"), ...deleteBackward(2)])).toBe( - "", - ); + it("returns inputValue after multiple deletes", () => { + expect(getInputFromDom([...insert("ab"), ...deleteBackward(2)])).toBe(""); }); - it("handles multiple deletes after space", () => { - expect(getInputFromEvents([...insert("ab "), ...deleteBackward(2)])).toBe( - "a", + it("returns inputValue after deleteWordBackward", () => { + expect(getInputFromDom([...insert("hello"), deleteWordBackward()])).toBe( + "", ); }); - it("handles deleteWordBackward", () => { - expect(getInputFromEvents([...insert("hello"), deleteWordBackward()])).toBe( + it("returns inputValue after deleteWordBackward across trailing space", () => { + expect(getInputFromDom([...insert("hello "), deleteWordBackward()])).toBe( "", ); }); - it("handles deleteWordBackward after space", () => { - expect( - getInputFromEvents([...insert("hello "), deleteWordBackward()]), - ).toBe(""); - }); - it("returns empty string for no events", () => { - expect(getInputFromEvents([])).toBe(""); + expect(getInputFromDom([])).toBe(""); }); - it("handles deleteContentBackward on empty string", () => { - const events = [...deleteBackward()]; - expect(getInputFromEvents(events)).toBe(""); + it("returns empty after deleteContentBackward on empty string", () => { + expect(getInputFromDom([...deleteBackward()])).toBe(""); }); - it("skips inputStopped events", () => { + it("inputStopped events keep prior inputValue", () => { + // inputStopped events should not advance currentInput in the helper, so + // the next character continues from "he" expect( - getInputFromEvents([ + getInputFromDom([ ...insert("he"), ...insert("x", "insertText", { inputStopped: true }), ...insert("llo"), @@ -159,27 +159,21 @@ describe("getInputFromEvents", () => { ).toBe("hello"); }); - it("handles deleteContentBackward within the same word correctly", () => { - expect(getInputFromEvents([...insert("a a"), deleteWordBackward()])).toBe( + it("returns inputValue after deleteWordBackward mid-word", () => { + expect(getInputFromDom([...insert("a a"), deleteWordBackward()])).toBe( "a ", ); }); - it("handles deleteWordBackward with multiple internal spaces", () => { + it("returns inputValue after deleteWordBackward with multiple words", () => { expect( - getInputFromEvents([...insert("foo bar baz"), deleteWordBackward()]), + getInputFromDom([...insert("foo bar baz"), deleteWordBackward()]), ).toBe("foo bar "); }); - it("handles deleteWordBackward with trailing space after multiple words", () => { - expect( - getInputFromEvents([...insert("foo bar "), deleteWordBackward()]), - ).toBe("foo "); - }); - - it("handles consecutive deleteWordBackward events", () => { + it("returns inputValue after consecutive deleteWordBackward events", () => { expect( - getInputFromEvents([ + getInputFromDom([ ...insert("foo bar baz"), deleteWordBackward(), deleteWordBackward(), @@ -187,113 +181,47 @@ describe("getInputFromEvents", () => { ).toBe("foo "); }); - it("handles deleteWordBackward on empty string", () => { - expect(getInputFromEvents([deleteWordBackward()])).toBe(""); - }); - - it("handles deleteWordBackward on only whitespace", () => { - expect(getInputFromEvents([...insert(" "), deleteWordBackward()])).toBe( - "", - ); - }); - - it("ignores recorded inputValue (pure op-based simulation)", () => { - const events: InputEvent[] = [ - ...insert("hello"), - { - type: "input", - ms: 100, - testMs: 100, - data: { - inputType: "deleteWordBackward", - charIndex: 5, - wordIndex: 0, - inputValue: "RECORDED_BUT_IGNORED", - }, - }, - ]; - // pure simulation: deleteWordBackward on "hello" → "" - expect(getInputFromEvents(events)).toBe(""); - }); -}); - -describe("getInputFromDom", () => { - beforeEach(() => { - reset(); - }); - - it("falls through to op-based logic when inputValue is absent", () => { - expect(getInputFromDom([...insert("hello")])).toBe("hello"); - }); - - it("uses recorded inputValue when present, overriding op-based logic", () => { - const events: InputEvent[] = [ - ...insert("hello"), - { - type: "input", - ms: 100, - testMs: 100, - data: { - inputType: "deleteWordBackward", - charIndex: 5, - wordIndex: 0, - inputValue: "he", - }, - }, - ]; - // op-based would yield "", but inputValue is truth - expect(getInputFromDom(events)).toBe("he"); - }); - - it("uses latest event's inputValue across multiple recorded events", () => { + it("trims trailing space when last event is incorrect last-word commit", () => { const events: InputEvent[] = [ - ...insert("hello"), + ...insert("hi"), { type: "input", ms: 100, testMs: 100, data: { - inputType: "deleteContentBackward", - charIndex: 5, + inputType: "insertText", + data: " ", + charIndex: 2, wordIndex: 0, - inputValue: "hi", + correct: false, + inputValue: "hi ", + commitsWord: true, + lastWord: true, }, }, ]; expect(getInputFromDom(events)).toBe("hi"); }); - it("mixes captured and op-based across events", () => { + it("does not trim trailing space when commit is on non-last word", () => { const events: InputEvent[] = [ - ...insert("ab"), // no inputValue, op = "ab" + ...insert("hi"), { type: "input", ms: 100, testMs: 100, data: { inputType: "insertText", - data: "c", + data: " ", charIndex: 2, wordIndex: 0, - correct: true, - inputValue: "abc", - }, - }, - // next event has no inputValue, falls through to op (append "d") - { - type: "input", - ms: 110, - testMs: 110, - data: { - inputType: "insertText", - data: "d", - charIndex: 3, - wordIndex: 0, - correct: true, + correct: false, + inputValue: "hi ", + commitsWord: true, }, }, ]; - expect(getInputFromDom(events)).toBe("abcd"); + expect(getInputFromDom(events)).toBe("hi "); }); }); diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 6a155295f95e..dbb3b1b20223 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -80,6 +80,8 @@ function keyUp(code: Keycode = "KeyA"): KeyupEventData { return { code }; } +const inputPerWord = new Map(); + function input( overrides: Partial<{ charIndex: number; @@ -89,9 +91,23 @@ function input( inputType: string; isCompositionEnding: boolean; inputStopped: boolean; - isCommitSpace: true; + commitsWord: true; + inputValue: string; }> = {}, ): InputEventData { + const wordIndex = overrides.wordIndex ?? 0; + const data = overrides.data ?? "a"; + const inputStopped = overrides.inputStopped ?? false; + + let inputValue: string; + if (overrides.inputValue !== undefined) { + inputValue = overrides.inputValue; + } else { + const prev = inputPerWord.get(wordIndex) ?? ""; + inputValue = inputStopped ? prev : prev + data; + inputPerWord.set(wordIndex, inputValue); + } + return { charIndex: 0, wordIndex: 0, @@ -100,6 +116,7 @@ function input( correct: true, isCompositionEnding: false, inputStopped: false, + inputValue, ...overrides, } as InputEventData; } @@ -142,6 +159,7 @@ describe("stats.ts", () => { (Config as { funbox: string[] }).funbox = []; (TestState as { activeWordIndex: number }).activeWordIndex = 0; TestWords.list.length = 0; + inputPerWord.clear(); }); describe("getTimerBoundaries", () => { @@ -823,7 +841,7 @@ describe("stats.ts", () => { charIndex: 3, wordIndex: 0, data: " ", - isCommitSpace: true, + commitsWord: true, }), ); // type "w" on second word diff --git a/frontend/__tests__/utils/strings.spec.ts b/frontend/__tests__/utils/strings.spec.ts index 697f9d02bae8..bd3ec258720f 100644 --- a/frontend/__tests__/utils/strings.spec.ts +++ b/frontend/__tests__/utils/strings.spec.ts @@ -585,29 +585,12 @@ describe("string utils", () => { describe("countChars", () => { describe("it should count characters correctly", () => { const testCases = [ - { - description: "correct, partial, not last", - input: { - inputWord: "hel", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 3, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 3, - }, - }, { description: "correct, partial, last, shouldnt count", input: { inputWord: "hel", targetWord: "hello ", creditPartial: false, - endsWithCommitSpace: false, }, expected: { allCorrect: 3, @@ -623,7 +606,6 @@ describe("string utils", () => { inputWord: "hel", targetWord: "hello ", creditPartial: true, - endsWithCommitSpace: false, }, expected: { allCorrect: 3, @@ -633,29 +615,12 @@ describe("string utils", () => { missed: 0, }, }, - { - description: "correct", - input: { - inputWord: "hello ", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 6, - correctWord: 6, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, { description: "correct last", input: { inputWord: "hello ", targetWord: "hello ", creditPartial: true, - endsWithCommitSpace: false, }, expected: { allCorrect: 6, @@ -671,7 +636,6 @@ describe("string utils", () => { inputWord: "hello", targetWord: "hello ", creditPartial: true, - endsWithCommitSpace: false, }, expected: { allCorrect: 5, @@ -681,175 +645,12 @@ describe("string utils", () => { missed: 0, }, }, - { - description: "correct with extra characters", - input: { - inputWord: "helloxxx ", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 5, - correctWord: 0, - incorrect: 1, - extra: 3, - missed: 0, - }, - }, - { - description: "early space", - input: { - inputWord: "hel ", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 3, - correctWord: 0, - incorrect: 1, - extra: 0, - missed: 2, - }, - }, - { - description: "all incorrect, early space", - input: { - inputWord: "xxx ", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 0, - correctWord: 0, - incorrect: 4, - extra: 0, - missed: 2, - }, - }, - { - description: "all incorrect, extra", - input: { - inputWord: "xxxxxx ", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 0, - correctWord: 0, - incorrect: 6, - extra: 1, - missed: 0, - }, - }, - { - description: "some correct, extra", - input: { - inputWord: "xexlxx ", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 2, - correctWord: 0, - incorrect: 4, - extra: 1, - missed: 0, - }, - }, - { - description: "some correct, early space", - input: { - inputWord: "xexl ", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 2, - correctWord: 0, - incorrect: 3, - extra: 0, - missed: 1, - }, - }, - { - description: - "last word, early commit space, input length == target length", - input: { - inputWord: "no ", - targetWord: "nom", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 2, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 1, - }, - }, - { - description: "last word correctly typed + commit space (past target)", - input: { - inputWord: "hello ", - targetWord: "hello", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 5, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, - { - description: "last word with extra char + commit space (past target)", - input: { - inputWord: "hellox ", - targetWord: "hello", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 5, - correctWord: 0, - incorrect: 0, - extra: 1, - missed: 0, - }, - }, - { - description: - "last word, early commit space, equal length, creditPartial (trailing space breaks prefix match)", - input: { - inputWord: "no ", - targetWord: "nom", - creditPartial: true, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 2, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, { description: "incorrect, last word, quick end", input: { inputWord: "xello", targetWord: "hello", creditPartial: false, - endsWithCommitSpace: false, }, expected: { allCorrect: 4, @@ -865,7 +666,6 @@ describe("string utils", () => { inputWord: "he ", targetWord: "hello", creditPartial: false, - endsWithCommitSpace: false, }, expected: { allCorrect: 2, @@ -881,7 +681,6 @@ describe("string utils", () => { inputWord: "xello ", targetWord: "hello", creditPartial: false, - endsWithCommitSpace: false, }, expected: { allCorrect: 4, @@ -891,29 +690,12 @@ describe("string utils", () => { missed: 0, }, }, - { - description: "correct space, incorrect word (commit space)", - input: { - inputWord: "helol ", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 3, - correctWord: 0, - incorrect: 3, - extra: 0, - missed: 0, - }, - }, { description: "correct space, incorrect word (literal space)", input: { inputWord: "helol ", targetWord: "hello ", creditPartial: false, - endsWithCommitSpace: false, }, expected: { allCorrect: 3, @@ -923,22 +705,6 @@ describe("string utils", () => { missed: 0, }, }, - { - description: "single incorrect char (commit space)", - input: { - inputWord: "hxllo ", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 4, - correctWord: 0, - incorrect: 2, - extra: 0, - missed: 0, - }, - }, { description: "single incorrect char (literal space — stopOnError=word)", @@ -946,7 +712,6 @@ describe("string utils", () => { inputWord: "hxllo ", targetWord: "hello ", creditPartial: false, - endsWithCommitSpace: false, }, expected: { allCorrect: 4, @@ -956,38 +721,6 @@ describe("string utils", () => { missed: 0, }, }, - { - description: "one extra char", - input: { - inputWord: "helloo ", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 5, - correctWord: 0, - incorrect: 1, - extra: 1, - missed: 0, - }, - }, - { - description: "missed chars, no trailing space on target", - input: { - inputWord: "hel", - targetWord: "hello", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 3, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 2, - }, - }, { description: "last partial match counts correctWord, no trailing space on target", @@ -995,7 +728,6 @@ describe("string utils", () => { inputWord: "hel", targetWord: "hello", creditPartial: true, - endsWithCommitSpace: false, }, expected: { allCorrect: 3, @@ -1011,7 +743,6 @@ describe("string utils", () => { inputWord: "xxx", targetWord: "hello", creditPartial: true, - endsWithCommitSpace: false, }, expected: { allCorrect: 0, @@ -1027,7 +758,6 @@ describe("string utils", () => { inputWord: "hel", targetWord: "hello", creditPartial: false, - endsWithCommitSpace: false, }, expected: { allCorrect: 3, @@ -1037,77 +767,12 @@ describe("string utils", () => { missed: 2, }, }, - { - description: "empty input counts all as missed", - input: { - inputWord: "", - targetWord: "hello", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 0, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 5, - }, - }, - { - description: "empty target counts all as extra", - input: { - inputWord: "hello", - targetWord: "", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 0, - correctWord: 0, - incorrect: 0, - extra: 5, - missed: 0, - }, - }, - { - description: "correctly count incorrect newlines", - input: { - inputWord: "hello ", - targetWord: "hello\n", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 5, - correctWord: 0, - incorrect: 1, - extra: 0, - missed: 0, - }, - }, - { - description: "partial correct, with space (commit)", - input: { - inputWord: "helxx ", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 3, - correctWord: 0, - incorrect: 3, - extra: 0, - missed: 0, - }, - }, { description: "partial correct, with space (literal)", input: { inputWord: "helxx ", targetWord: "hello ", creditPartial: false, - endsWithCommitSpace: false, }, expected: { allCorrect: 3, @@ -1117,29 +782,12 @@ describe("string utils", () => { missed: 0, }, }, - { - description: "newlines", - input: { - inputWord: "hello\n", - targetWord: "hello\n", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 6, - correctWord: 6, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, { description: "count extra chars as extra", input: { inputWord: "abcx", targetWord: "abc ", creditPartial: true, - endsWithCommitSpace: false, }, expected: { allCorrect: 3, @@ -1155,7 +803,6 @@ describe("string utils", () => { inputWord: "abcx ", targetWord: "abc ", creditPartial: true, - endsWithCommitSpace: false, }, expected: { allCorrect: 3, @@ -1172,7 +819,6 @@ describe("string utils", () => { inputWord: "jhow ", targetWord: "how", creditPartial: true, - endsWithCommitSpace: false, }, expected: { allCorrect: 0, @@ -1189,7 +835,6 @@ describe("string utils", () => { inputWord: "xow ", targetWord: "how", creditPartial: true, - endsWithCommitSpace: false, }, expected: { allCorrect: 2, @@ -1206,7 +851,6 @@ describe("string utils", () => { inputWord: "xonl ", targetWord: "only ", creditPartial: true, - endsWithCommitSpace: false, }, expected: { allCorrect: 0, @@ -1223,7 +867,6 @@ describe("string utils", () => { inputWord: "x ", targetWord: "get", creditPartial: true, - endsWithCommitSpace: false, }, expected: { allCorrect: 0, @@ -1233,40 +876,6 @@ describe("string utils", () => { missed: 0, }, }, - { - description: - "trailing commit-space append past target — not counted (correct last word + commit)", - input: { - inputWord: "how ", - targetWord: "how", - creditPartial: true, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 3, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, - { - description: - "incorrect word with commit space — wrong word advanced (stopOnError=off)", - input: { - inputWord: "xello ", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 4, - correctWord: 0, - incorrect: 2, - extra: 0, - missed: 0, - }, - }, { description: "incorrect word with literal trailing space — uncommitted (stopOnError=word)", @@ -1274,7 +883,6 @@ describe("string utils", () => { inputWord: "xello ", targetWord: "hello ", creditPartial: false, - endsWithCommitSpace: false, }, expected: { allCorrect: 4, @@ -1284,77 +892,12 @@ describe("string utils", () => { missed: 0, }, }, - { - description: "early space on last word", - input: { - inputWord: "h ", - targetWord: "hello", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 1, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 4, - }, - }, - { - description: "early space on last word with creditPartial", - input: { - inputWord: "h ", - targetWord: "hello", - creditPartial: true, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 1, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, - { - description: "wrong word, trailing commit-space past target", - input: { - inputWord: "xow ", - targetWord: "how", - creditPartial: true, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 2, - correctWord: 0, - incorrect: 1, - extra: 0, - missed: 0, - }, - }, - { - description: "both empty", - input: { - inputWord: "", - targetWord: "", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 0, - correctWord: 0, - incorrect: 0, - extra: 0, - missed: 0, - }, - }, { description: "empty input with creditPartial", input: { inputWord: "", targetWord: "hello", creditPartial: true, - endsWithCommitSpace: false, }, expected: { allCorrect: 0, @@ -1370,7 +913,6 @@ describe("string utils", () => { inputWord: "hello", targetWord: "hello", creditPartial: false, - endsWithCommitSpace: false, }, expected: { allCorrect: 5, @@ -1386,7 +928,6 @@ describe("string utils", () => { inputWord: "hel o", targetWord: "hello ", creditPartial: false, - endsWithCommitSpace: false, }, expected: { allCorrect: 4, @@ -1396,22 +937,6 @@ describe("string utils", () => { missed: 1, }, }, - { - description: "newline typed in place of target's trailing space", - input: { - inputWord: "hello\n", - targetWord: "hello ", - creditPartial: false, - endsWithCommitSpace: true, - }, - expected: { - allCorrect: 5, - correctWord: 0, - incorrect: 0, - extra: 1, - missed: 0, - }, - }, ]; it.each(testCases)("$description", ({ input, expected }) => { @@ -1420,16 +945,9 @@ describe("string utils", () => { input.inputWord, input.targetWord, input.creditPartial, - input.endsWithCommitSpace, ), ).toEqual(expected); }); }); - - it("early space (typed before reaching target's space) counts as incorrect", () => { - // non-last word: space commits the (wrong) word, so endsWithCommitSpace=true - const result = Strings.countChars("hell ", "hello ", false, true); - expect(result.incorrect).toBe(1); - }); }); }); diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index d65a87999955..4d6d46d404f5 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -235,6 +235,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { inputValue: inputValueAfterEvent + (charIsSpace && !shouldInsertSpace ? " " : ""), commitsWord: shouldGoToNextWord ? true : undefined, + lastWord: wordIndex === TestWords.words.length - 1 ? true : undefined, }); // going to next word diff --git a/frontend/src/ts/test/events/helpers.ts b/frontend/src/ts/test/events/helpers.ts index 09411f352553..96302dbd8fcc 100644 --- a/frontend/src/ts/test/events/helpers.ts +++ b/frontend/src/ts/test/events/helpers.ts @@ -93,9 +93,18 @@ export function getTestEventCode(event: KeyboardEvent): Keycode | "NoCode" { return event.code as Keycode; } -export function applyOp(input: string, event: InputEventNoMs): string { +export function applyInputEvent(input: string, event: InputEventNoMs): string { if (event.data.inputType === "insertText") { if (event.data.inputStopped) return input; + if ( + event.data.data === " " && + event.data.lastWord && + event.data.commitsWord && + !event.data.correct + ) { + // if this is an incorrect word commit on the last word, we dont want to count it at all + return input; + } return input + event.data.data; } if (event.data.inputType === "insertCompositionText") { @@ -111,19 +120,6 @@ export function applyOp(input: string, event: InputEventNoMs): string { return input; } -/** - * Derives input by applying each event's operation in order. Ignores the - * recorded inputValue field. Use for verification, tests, or fallback — - * not as source of truth. - */ -export function getInputFromEvents(events: InputEventNoMs[]): string { - let input = ""; - for (const event of events) { - input = applyOp(input, event); - } - return input; -} - /** * Reads input from the DOM snapshots captured on each event (inputValue), * falling back to op-based derivation for events without a snapshot. @@ -134,17 +130,30 @@ export function getInputFromEvents(events: InputEventNoMs[]): string { * snapshot (the common case), O(n) worst case. */ export function getInputFromDom(events: InputEventNoMs[]): string { - for (let i = events.length - 1; i >= 0; i--) { - const event = events[i] as InputEventNoMs; - if (event.data.inputValue !== undefined) { - let input = event.data.inputValue; - for (let j = i + 1; j < events.length; j++) { - input = applyOp(input, events[j] as InputEventNoMs); - } - return input; + const lastEvent = events[events.length - 1]; + + if (lastEvent === undefined) { + let input = ""; + for (const event of events) { + input = applyInputEvent(input, event); } + return input; } - return getInputFromEvents(events); + + const inputValue = lastEvent.data.inputValue; + + if ( + lastEvent.data.inputType === "insertText" && + lastEvent.data.data === " " && + lastEvent.data.lastWord && + lastEvent.data.commitsWord && + !lastEvent.data.correct + ) { + // if this is an incorrect word commit on the last word, we dont want to count it at all + return inputValue.trimEnd(); + } + + return inputValue; } export type InputValueMismatch = { @@ -166,7 +175,7 @@ export function findInputValueMismatches( for (let i = 0; i < events.length; i++) { const event = events[i] as InputEventNoMs; - derived = applyOp(derived, event); + derived = applyInputEvent(derived, event); if ( event.data.inputValue !== undefined && diff --git a/frontend/src/ts/test/events/stats.ts b/frontend/src/ts/test/events/stats.ts index 3b8a14f2aa47..2e5bbe524fdf 100644 --- a/frontend/src/ts/test/events/stats.ts +++ b/frontend/src/ts/test/events/stats.ts @@ -291,17 +291,10 @@ function countCharsForWords( targetWord = Hangul.disassemble(targetWord).join(""); } - const lastEvent = events[events.length - 1]; - const endsWithCommitSpace = - lastEvent !== undefined && - lastEvent.data.inputType === "insertText" && - lastEvent.data.commitsWord === true; - const c = countChars( simulatedInput, targetWord, lastWord && shouldCountPartialLastWord, - endsWithCommitSpace, ); acc.allCorrect += c.allCorrect; acc.correctWord += c.correctWord; diff --git a/frontend/src/ts/test/events/types.ts b/frontend/src/ts/test/events/types.ts index 06afb2b07aad..09571e45b78f 100644 --- a/frontend/src/ts/test/events/types.ts +++ b/frontend/src/ts/test/events/types.ts @@ -81,7 +81,7 @@ export type InputEvent = EventProps<"input", InputEventData>; type BaseInputEventData = { charIndex: number; wordIndex: number; - inputValue?: string; + inputValue: string; }; export type InputEventData = @@ -94,6 +94,7 @@ export type InputEventData = // true when this was a space that advanced to the next word (commit // attempt) rather than being inserted as a literal character commitsWord?: true; + lastWord?: true; }) | (BaseInputEventData & { inputType: DeleteInputType; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 3a7c4912a7cb..576bc5f2104e 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1253,13 +1253,19 @@ function compareCompletedEvents( { const a = getInputHistory().join(" "); - const b = getEventsInputHistory().join(""); - if (a === b) { - console.debug(`Completed event match on input history:`, a); - } else { - notMatching.push(`input history (values differ)`); - mismatchedKeys.push("inputHistory"); - console.error(`Completed event mismatch on input history:`, a, b); + if (!a.includes("\n")) { + const b = getEventsInputHistory().join(""); + if (a === b) { + console.debug(`Completed event match on input history:`, a); + } else { + notMatching.push(`input history (values differ)`); + mismatchedKeys.push("inputHistory"); + console.error( + `Completed event mismatch on input history:`, + getInputHistory(), + getEventsInputHistory(), + ); + } } } @@ -1335,7 +1341,7 @@ function compareCompletedEvents( difficulty: ce.difficulty, duration: ce.testDuration, funboxes: getActiveFunboxNames().join(","), - version: 20, + version: 22, data: { words: TestWords.words.list.join(" "), events: getAllTestEvents(), diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 6ff0768bcb07..9adb97dc60ad 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -291,8 +291,6 @@ function countChars(final = false): CharCount { inputWord, targetWord, isLastInputWord && ((isTimedTest && final) || !final), - // historical words advanced via commit space; last is in-flight - !isLastInputWord, ); correctWordChars += correctWord; diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index 9599e4de4d70..13e1dee0222e 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -412,7 +412,6 @@ export function countChars( inputWord: string, targetWord: string, creditPartial: boolean, - endsWithCommitSpace: boolean, ): CharCounts { let allCorrect = 0; let correctWord = 0; @@ -428,17 +427,8 @@ export function countChars( const targetChar = targetWord[i]; if (inputChar === targetChar) { - // matching space on a wrong word: incorrect if it was a commit attempt - // (word advanced via space), extra if it was a literal space (stopOnError - // blocked the commit so the space ended up as a typed character) - if (targetChar === " ") { - if (wordCorrect) { - allCorrect += 1; - } else if (endsWithCommitSpace) { - incorrect += 1; - } else { - extra += 1; - } + if (targetChar === " " && !wordCorrect) { + extra += 1; } else { allCorrect += 1; } @@ -450,18 +440,6 @@ export function countChars( if (!creditPartial) { missed += 1; } - } else if ( - endsWithCommitSpace && - inputChar === " " && - i === inputWord.length - 1 && - !targetWord.endsWith(" ") && - targetChar !== "\n" - ) { - // commit-space on last word — not a literal typed char. If it landed - // before reaching target's end, that slot is effectively missed. - if (targetChar !== undefined && !creditPartial) { - missed += 1; - } } else if ( targetChar === undefined || (targetChar === " " && inputChar !== " " && !inputWord.includes(" ")) diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index af591d2746a5..45debaccbef9 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -75,7 +75,7 @@ export const ReportCompletedEventMismatchRequestSchema = z.object({ difficulty: DifficultySchema.optional(), duration: z.number().max(200).optional(), funboxes: z.string().max(100).optional(), - version: z.literal(20), + version: z.literal(22), data: z.object({ words: z.string().max(10000), events: z.array(z.record(z.unknown())),