From ab7ea190429d7a23b6c17fd706711db1c1a39703 Mon Sep 17 00:00:00 2001 From: Edneam Date: Thu, 14 May 2026 15:27:30 +0530 Subject: [PATCH 1/4] fix(input): support oe and ae ligature input --- .../input/handlers/insert-text.spec.ts | 33 +++++++++++++++++++ frontend/src/ts/input/handlers/insert-text.ts | 22 +++++++++++++ frontend/src/ts/input/helpers/ligatures.ts | 32 ++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 frontend/__tests__/input/handlers/insert-text.spec.ts create mode 100644 frontend/src/ts/input/helpers/ligatures.ts diff --git a/frontend/__tests__/input/handlers/insert-text.spec.ts b/frontend/__tests__/input/handlers/insert-text.spec.ts new file mode 100644 index 000000000000..eb06cf62283b --- /dev/null +++ b/frontend/__tests__/input/handlers/insert-text.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + getMatchingLigatureOverride, + shouldIgnoreLigatureCompletion, +} from "../../../src/ts/input/helpers/ligatures"; + +describe("insert-text ligature input overrides", () => { + it.each([ + ["o", "œ", "œ"], + ["O", "Œ", "Œ"], + ["a", "æ", "æ"], + ["A", "Æ", "Æ"], + ])( + "normalizes '%s' to '%s' when target is '%s'", + (data, target, expected) => { + expect(getMatchingLigatureOverride(data, target)).toBe(expected); + }, + ); + + it.each([ + ["e", "œ", "œuvre"], + ["E", "Œ", "ŒUVRE"], + ["e", "æ", "æther"], + ["E", "Æ", "ÆTHER"], + ])("ignores completion '%s' after '%s'", (data, input, word) => { + expect(shouldIgnoreLigatureCompletion(data, input, word)).toBe(true); + }); + + it("does not normalize unrelated input", () => { + expect(getMatchingLigatureOverride("e", "œ")).toBeNull(); + expect(shouldIgnoreLigatureCompletion("u", "œ", "œuvre")).toBe(false); + }); +}); diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index ff19d06333e9..1eff05eac24b 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -37,6 +37,10 @@ import { isCharCorrect, shouldInsertSpaceCharacter, } from "../helpers/validation"; +import { + getMatchingLigatureOverride, + shouldIgnoreLigatureCompletion, +} from "../helpers/ligatures"; const charOverrides = new Map([ ["…", "..."], @@ -82,6 +86,18 @@ export async function onInsertText(options: OnInsertTextParams): Promise { return; } + if ( + shouldIgnoreLigatureCompletion( + options.data, + TestInput.input.current, + TestWords.words.getCurrentText(), + ) + ) { + setInputElementValue(inputValue.slice(0, -options.data.length)); + TestInput.input.syncWithInputElement(); + return; + } + const charOverride = charOverrides.get(options.data); if ( charOverride !== undefined && @@ -301,6 +317,12 @@ function normalizeDataAndUpdateInputIfNeeded( ) { replaceInputElementLastValueChar(targetChar); normalizedData = targetChar; + } else { + const ligatureOverride = getMatchingLigatureOverride(data, targetChar); + if (ligatureOverride !== null) { + replaceInputElementLastValueChar(ligatureOverride); + normalizedData = ligatureOverride; + } } return normalizedData; } diff --git a/frontend/src/ts/input/helpers/ligatures.ts b/frontend/src/ts/input/helpers/ligatures.ts new file mode 100644 index 000000000000..ef3d710e9e93 --- /dev/null +++ b/frontend/src/ts/input/helpers/ligatures.ts @@ -0,0 +1,32 @@ +const ligatureInputOverrides = new Map([ + ["œ", "oe"], + ["Œ", "OE"], + ["æ", "ae"], + ["Æ", "AE"], +]); + +export function getMatchingLigatureOverride( + data: string, + targetChar: string | undefined, +): string | null { + if (targetChar === undefined) return null; + + const override = ligatureInputOverrides.get(targetChar); + if (override?.[0] !== data) return null; + + return targetChar; +} + +export function shouldIgnoreLigatureCompletion( + data: string, + testInput: string, + currentWord: string, +): boolean { + const previousTargetChar = currentWord[testInput.length - 1]; + if (previousTargetChar === undefined) return false; + + const override = ligatureInputOverrides.get(previousTargetChar); + if (override === undefined) return false; + + return testInput.endsWith(previousTargetChar) && data === override.slice(1); +} From e7bec61f9581597f89ad96a2382d0e9164bb8b18 Mon Sep 17 00:00:00 2001 From: Edneam Date: Thu, 14 May 2026 15:42:13 +0530 Subject: [PATCH 2/4] test(input): cover ligature completion handling --- .../input/handlers/insert-text.spec.ts | 119 +++++++++++++++++- frontend/src/ts/input/helpers/ligatures.ts | 8 +- 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/frontend/__tests__/input/handlers/insert-text.spec.ts b/frontend/__tests__/input/handlers/insert-text.spec.ts index eb06cf62283b..35e33c443802 100644 --- a/frontend/__tests__/input/handlers/insert-text.spec.ts +++ b/frontend/__tests__/input/handlers/insert-text.spec.ts @@ -1,10 +1,111 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getInputElementValue, + setInputElementValue, +} from "../../../src/ts/input/input-element"; +import { onInsertText } from "../../../src/ts/input/handlers/insert-text"; import { getMatchingLigatureOverride, shouldIgnoreLigatureCompletion, } from "../../../src/ts/input/helpers/ligatures"; +const mocks = vi.hoisted(() => ({ + currentWord: "", + input: { + current: "", + syncWithInputElement: vi.fn(), + }, +})); + +vi.mock("../../../src/ts/test/test-ui", () => ({})); +vi.mock("../../../src/ts/test/test-state", () => ({ + activeWordIndex: 0, + isActive: true, +})); +vi.mock("../../../src/ts/test/test-logic", () => ({ + startTest: vi.fn(), +})); +vi.mock("../../../src/ts/test/test-input", () => ({ + input: mocks.input, + corrected: { update: vi.fn() }, + incrementAccuracy: vi.fn(), + incrementKeypressCount: vi.fn(), + incrementKeypressErrors: vi.fn(), + pushKeypressWord: vi.fn(), + pushMissedWord: vi.fn(), + setBurstStart: vi.fn(), + setCurrentNotAfk: vi.fn(), +})); +vi.mock("../../../src/ts/test/test-words", () => ({ + words: { + getCurrentText: vi.fn(() => mocks.currentWord), + }, +})); +vi.mock("../../../src/ts/input/helpers/fail-or-finish", () => ({ + checkIfFailedDueToDifficulty: vi.fn(), + checkIfFailedDueToMinBurst: vi.fn(), + checkIfFinished: vi.fn(), +})); +vi.mock("../../../src/ts/test/funbox/list", () => ({ + findSingleActiveFunboxWithFunction: vi.fn(), + isFunboxActiveWithProperty: vi.fn(() => false), +})); +vi.mock("../../../src/ts/test/replay", () => ({ + addReplayEvent: vi.fn(), +})); +vi.mock("../../../src/ts/config/store", () => ({ + Config: { + blindMode: false, + keymapMode: "off", + language: "english", + mode: "words", + oppositeShiftMode: "off", + stopOnError: "off", + }, +})); +vi.mock("../../../src/ts/events/keymap", () => ({ + flash: vi.fn(), +})); +vi.mock("../../../src/ts/test/weak-spot", () => ({ + updateScore: vi.fn(), +})); +vi.mock("../../../src/ts/legacy-states/composition", () => ({ + getData: vi.fn(() => ""), +})); +vi.mock("../../../src/ts/input/state", () => ({ + getIncorrectShiftsInARow: vi.fn(() => 0), + incrementIncorrectShiftsInARow: vi.fn(), + isCorrectShiftUsed: vi.fn(() => true), + resetIncorrectShiftsInARow: vi.fn(), +})); +vi.mock("../../../src/ts/states/notifications", () => ({ + showNoticeNotification: vi.fn(), +})); +vi.mock("../../../src/ts/input/helpers/word-navigation", () => ({ + goToNextWord: vi.fn(async () => ({ + increasedWordIndex: false, + lastBurst: null, + })), +})); +vi.mock("../../../src/ts/input/handlers/before-insert-text", () => ({ + onBeforeInsertText: vi.fn(), +})); + describe("insert-text ligature input overrides", () => { + beforeEach(() => { + mocks.currentWord = ""; + mocks.input.current = ""; + mocks.input.syncWithInputElement.mockImplementation(() => { + mocks.input.current = getInputElementValue().inputValue; + }); + setInputElementValue(""); + }); + + afterEach(() => { + vi.clearAllMocks(); + setInputElementValue(""); + }); + it.each([ ["o", "œ", "œ"], ["O", "Œ", "Œ"], @@ -22,6 +123,7 @@ describe("insert-text ligature input overrides", () => { ["E", "Œ", "ŒUVRE"], ["e", "æ", "æther"], ["E", "Æ", "ÆTHER"], + ["e", "bœ", "bœuf"], ])("ignores completion '%s' after '%s'", (data, input, word) => { expect(shouldIgnoreLigatureCompletion(data, input, word)).toBe(true); }); @@ -30,4 +132,19 @@ describe("insert-text ligature input overrides", () => { expect(getMatchingLigatureOverride("e", "œ")).toBeNull(); expect(shouldIgnoreLigatureCompletion("u", "œ", "œuvre")).toBe(false); }); + + it("removes the completion character and keeps input state synced", async () => { + mocks.currentWord = "œuvre"; + mocks.input.current = "œ"; + setInputElementValue("œe"); + + await onInsertText({ + now: performance.now(), + data: "e", + }); + + expect(getInputElementValue().inputValue).toBe("œ"); + expect(mocks.input.current).toBe("œ"); + expect(mocks.input.syncWithInputElement).toHaveBeenCalledOnce(); + }); }); diff --git a/frontend/src/ts/input/helpers/ligatures.ts b/frontend/src/ts/input/helpers/ligatures.ts index ef3d710e9e93..baeb2de42fe9 100644 --- a/frontend/src/ts/input/helpers/ligatures.ts +++ b/frontend/src/ts/input/helpers/ligatures.ts @@ -19,14 +19,16 @@ export function getMatchingLigatureOverride( export function shouldIgnoreLigatureCompletion( data: string, - testInput: string, + currentInput: string, currentWord: string, ): boolean { - const previousTargetChar = currentWord[testInput.length - 1]; + const previousTargetChar = currentWord[currentInput.length - 1]; if (previousTargetChar === undefined) return false; const override = ligatureInputOverrides.get(previousTargetChar); if (override === undefined) return false; - return testInput.endsWith(previousTargetChar) && data === override.slice(1); + return ( + currentInput.endsWith(previousTargetChar) && data === override.slice(1) + ); } From 452b327ef3ee603dd47f8c622b9e713043553c46 Mon Sep 17 00:00:00 2001 From: Edneam Date: Fri, 15 May 2026 08:17:11 +0530 Subject: [PATCH 3/4] fix(input): track ligature completion input --- .../input/handlers/insert-text.spec.ts | 52 ++++++++++++++----- frontend/src/ts/input/handlers/insert-text.ts | 40 ++++++++------ frontend/src/ts/input/helpers/ligatures.ts | 48 +++++++++++++---- 3 files changed, 103 insertions(+), 37 deletions(-) diff --git a/frontend/__tests__/input/handlers/insert-text.spec.ts b/frontend/__tests__/input/handlers/insert-text.spec.ts index 35e33c443802..27fd33fe07bf 100644 --- a/frontend/__tests__/input/handlers/insert-text.spec.ts +++ b/frontend/__tests__/input/handlers/insert-text.spec.ts @@ -5,8 +5,9 @@ import { } from "../../../src/ts/input/input-element"; import { onInsertText } from "../../../src/ts/input/handlers/insert-text"; import { + getLigatureCompletion, getMatchingLigatureOverride, - shouldIgnoreLigatureCompletion, + resetPendingLigatureCompletion, } from "../../../src/ts/input/helpers/ligatures"; const mocks = vi.hoisted(() => ({ @@ -15,6 +16,7 @@ const mocks = vi.hoisted(() => ({ current: "", syncWithInputElement: vi.fn(), }, + incrementKeypressErrors: vi.fn(), })); vi.mock("../../../src/ts/test/test-ui", () => ({})); @@ -30,7 +32,7 @@ vi.mock("../../../src/ts/test/test-input", () => ({ corrected: { update: vi.fn() }, incrementAccuracy: vi.fn(), incrementKeypressCount: vi.fn(), - incrementKeypressErrors: vi.fn(), + incrementKeypressErrors: mocks.incrementKeypressErrors, pushKeypressWord: vi.fn(), pushMissedWord: vi.fn(), setBurstStart: vi.fn(), @@ -103,6 +105,7 @@ describe("insert-text ligature input overrides", () => { afterEach(() => { vi.clearAllMocks(); + resetPendingLigatureCompletion(); setInputElementValue(""); }); @@ -119,23 +122,28 @@ describe("insert-text ligature input overrides", () => { ); it.each([ - ["e", "œ", "œuvre"], - ["E", "Œ", "ŒUVRE"], - ["e", "æ", "æther"], - ["E", "Æ", "ÆTHER"], - ["e", "bœ", "bœuf"], - ])("ignores completion '%s' after '%s'", (data, input, word) => { - expect(shouldIgnoreLigatureCompletion(data, input, word)).toBe(true); + ["œ", "e"], + ["Œ", "E"], + ["æ", "e"], + ["Æ", "E"], + ])("gets completion '%s' after '%s'", (target, completion) => { + expect(getLigatureCompletion(target)).toBe(completion); }); it("does not normalize unrelated input", () => { expect(getMatchingLigatureOverride("e", "œ")).toBeNull(); - expect(shouldIgnoreLigatureCompletion("u", "œ", "œuvre")).toBe(false); + expect(getLigatureCompletion("o")).toBeNull(); }); it("removes the completion character and keeps input state synced", async () => { mocks.currentWord = "œuvre"; - mocks.input.current = "œ"; + + setInputElementValue("o"); + await onInsertText({ + now: performance.now(), + data: "o", + }); + setInputElementValue("œe"); await onInsertText({ @@ -145,6 +153,26 @@ describe("insert-text ligature input overrides", () => { expect(getInputElementValue().inputValue).toBe("œ"); expect(mocks.input.current).toBe("œ"); - expect(mocks.input.syncWithInputElement).toHaveBeenCalledOnce(); + expect(mocks.input.syncWithInputElement).toHaveBeenCalledTimes(2); + expect(mocks.incrementKeypressErrors).not.toHaveBeenCalled(); + }); + + it("penalizes skipping the ligature completion character", async () => { + mocks.currentWord = "œuvre"; + + setInputElementValue("o"); + await onInsertText({ + now: performance.now(), + data: "o", + }); + + setInputElementValue("œu"); + await onInsertText({ + now: performance.now(), + data: "u", + }); + + expect(mocks.input.current).toBe("œu"); + expect(mocks.incrementKeypressErrors).toHaveBeenCalledOnce(); }); }); diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 1eff05eac24b..723d974b43f8 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -38,8 +38,10 @@ import { shouldInsertSpaceCharacter, } from "../helpers/validation"; import { + getLigatureCompletion, getMatchingLigatureOverride, - shouldIgnoreLigatureCompletion, + getPendingLigatureCompletionStatus, + setPendingLigatureCompletion, } from "../helpers/ligatures"; const charOverrides = new Map([ @@ -86,13 +88,11 @@ export async function onInsertText(options: OnInsertTextParams): Promise { return; } - if ( - shouldIgnoreLigatureCompletion( - options.data, - TestInput.input.current, - TestWords.words.getCurrentText(), - ) - ) { + const pendingLigatureCompletionStatus = getPendingLigatureCompletionStatus( + options.data, + TestInput.input.current, + ); + if (pendingLigatureCompletionStatus === "complete") { setInputElementValue(inputValue.slice(0, -options.data.length)); TestInput.input.syncWithInputElement(); return; @@ -156,13 +156,15 @@ export async function onInsertText(options: OnInsertTextParams): Promise { currentWord[(testInput + data).length - 1] ?? "", ); const correct = - funboxCorrect ?? - isCharCorrect({ - data, - inputValue: testInput, - targetWord: currentWord, - correctShiftUsed, - }); + pendingLigatureCompletionStatus === "skipped" + ? false + : funboxCorrect ?? + isCharCorrect({ + data, + inputValue: testInput, + targetWord: currentWord, + correctShiftUsed, + }); // word navigation check const noSpaceForce = @@ -322,6 +324,14 @@ function normalizeDataAndUpdateInputIfNeeded( if (ligatureOverride !== null) { replaceInputElementLastValueChar(ligatureOverride); normalizedData = ligatureOverride; + + const ligatureCompletion = getLigatureCompletion(targetChar); + if (ligatureCompletion !== null) { + setPendingLigatureCompletion( + ligatureCompletion, + testInput.length + ligatureOverride.length, + ); + } } } return normalizedData; diff --git a/frontend/src/ts/input/helpers/ligatures.ts b/frontend/src/ts/input/helpers/ligatures.ts index baeb2de42fe9..85edb0067c0f 100644 --- a/frontend/src/ts/input/helpers/ligatures.ts +++ b/frontend/src/ts/input/helpers/ligatures.ts @@ -5,6 +5,13 @@ const ligatureInputOverrides = new Map([ ["Æ", "AE"], ]); +let pendingLigatureCompletion: { + completion: string; + inputLength: number; +} | null = null; + +type PendingLigatureCompletionStatus = "complete" | "skipped" | null; + export function getMatchingLigatureOverride( data: string, targetChar: string | undefined, @@ -17,18 +24,39 @@ export function getMatchingLigatureOverride( return targetChar; } -export function shouldIgnoreLigatureCompletion( +export function getLigatureCompletion( + targetChar: string | undefined, +): string | null { + if (targetChar === undefined) return null; + + const override = ligatureInputOverrides.get(targetChar); + return override?.slice(1) ?? null; +} + +export function setPendingLigatureCompletion( + completion: string, + inputLength: number, +): void { + pendingLigatureCompletion = { completion, inputLength }; +} + +export function resetPendingLigatureCompletion(): void { + pendingLigatureCompletion = null; +} + +export function getPendingLigatureCompletionStatus( data: string, currentInput: string, - currentWord: string, -): boolean { - const previousTargetChar = currentWord[currentInput.length - 1]; - if (previousTargetChar === undefined) return false; +): PendingLigatureCompletionStatus { + if (pendingLigatureCompletion === null) return null; + + if (currentInput.length !== pendingLigatureCompletion.inputLength) { + resetPendingLigatureCompletion(); + return null; + } - const override = ligatureInputOverrides.get(previousTargetChar); - if (override === undefined) return false; + const completionMatched = data === pendingLigatureCompletion.completion; + resetPendingLigatureCompletion(); - return ( - currentInput.endsWith(previousTargetChar) && data === override.slice(1) - ); + return completionMatched ? "complete" : "skipped"; } From f055aac3b4a7081ad2d0c936b6b2f814adad3930 Mon Sep 17 00:00:00 2001 From: Edneam Date: Fri, 15 May 2026 08:23:01 +0530 Subject: [PATCH 4/4] style(input): format ligature completion check --- frontend/src/ts/input/handlers/insert-text.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 723d974b43f8..24931ff21a61 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -158,13 +158,13 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const correct = pendingLigatureCompletionStatus === "skipped" ? false - : funboxCorrect ?? + : (funboxCorrect ?? isCharCorrect({ data, inputValue: testInput, targetWord: currentWord, correctShiftUsed, - }); + })); // word navigation check const noSpaceForce =