diff --git a/src/adapters/chrome/background/PresageEngine.ts b/src/adapters/chrome/background/PresageEngine.ts index e51be13a..6e18cbc9 100644 --- a/src/adapters/chrome/background/PresageEngine.ts +++ b/src/adapters/chrome/background/PresageEngine.ts @@ -11,12 +11,16 @@ export interface PresageEngineConfig { } export class PresageEngine { + private readonly Module: PresageModule; + private readonly lang: string; public libPresage: Presage; private libPresageCallback: PresageCallback; private libPresageCallbackImpl: unknown = {}; private config: PresageEngineConfig; constructor(Module: PresageModule, config: PresageEngineConfig, lang: string) { + this.Module = Module; + this.lang = lang; this.config = config; this.libPresageCallback = { @@ -29,10 +33,7 @@ export class PresageEngine { }, }; this.libPresageCallbackImpl = Module.PresageCallback.implement(this.libPresageCallback); - this.libPresage = new Module.Presage( - this.libPresageCallbackImpl, - `resources_js/${lang}/presage.xml`, - ); + this.libPresage = this.createLibPresage(); this.setConfig(config); } @@ -41,6 +42,11 @@ export class PresageEngine { this.libPresage.config("Presage.Selector.SUGGESTIONS", this.config.numSuggestions.toString()); } + reinitialize(): void { + this.libPresage = this.createLibPresage(); + this.setConfig(this.config); + } + predict(predictionInput: string): string[] { this.libPresageCallback.pastStream = predictionInput; const predictions: string[] = []; @@ -59,4 +65,11 @@ export class PresageEngine { } return predictions; } + + private createLibPresage(): Presage { + return new this.Module.Presage( + this.libPresageCallbackImpl, + `resources_js/${this.lang}/presage.xml`, + ); + } } diff --git a/src/adapters/chrome/background/PresageHandler.ts b/src/adapters/chrome/background/PresageHandler.ts index 75232451..06c8434f 100644 --- a/src/adapters/chrome/background/PresageHandler.ts +++ b/src/adapters/chrome/background/PresageHandler.ts @@ -64,6 +64,8 @@ export class PresageHandler { private timeFormat?: string; private dateFormat?: string; private engineNumSuggestions: number; + private textExpansionsSignature = ""; + private userDictionarySignature = ""; constructor(Module: PresageModule) { const engineConfig: PresageEngineConfig = { @@ -102,6 +104,12 @@ export class PresageHandler { } setConfig(config: PresageConfig): void { + const textExpansionsSignature = JSON.stringify(config.textExpansions ?? []); + const userDictionarySignature = JSON.stringify(config.userDictionaryList ?? []); + const shouldRefreshEngines = + textExpansionsSignature !== this.textExpansionsSignature || + userDictionarySignature !== this.userDictionarySignature; + this.numSuggestions = config.numSuggestions; this.engineNumSuggestions = Math.min( MAX_NUM_SUGGESTIONS, @@ -116,6 +124,13 @@ export class PresageHandler { this.dateFormat = config.dateFormat; this.userDictionaryList = config.userDictionaryList || []; + if (shouldRefreshEngines) { + this.refreshPresageEngines(); + this.resetLastPredictionState(); + this.textExpansionsSignature = textExpansionsSignature; + this.userDictionarySignature = userDictionarySignature; + } + this.textExpansionManager.setTextExpansions(config.textExpansions); this.userDictionaryManager.setUserDictionaryList(this.userDictionaryList); @@ -360,4 +375,16 @@ export class PresageHandler { } return ""; } + + private refreshPresageEngines(): void { + for (const presageEngine of Object.values(this.presageEngines)) { + presageEngine.reinitialize(); + } + } + + private resetLastPredictionState(): void { + for (const lang of Object.keys(this.presageEngines)) { + this.lastPrediction[lang] = { pastStream: "", templates: [] }; + } + } } diff --git a/src/ui/options/TextAssetsPanel.ts b/src/ui/options/TextAssetsPanel.ts index 06a57231..71b08fb5 100644 --- a/src/ui/options/TextAssetsPanel.ts +++ b/src/ui/options/TextAssetsPanel.ts @@ -188,7 +188,7 @@ export class TextAssetsPanel { const imported = parsed .filter((row) => row.length === 2) .map((row) => [toTextValue(row[0]), toTextValue(row[1])] as TextExpansionEntry); - this.syncPersistedRows(this.mergeExpansions(imported, this.getPersistedExpansions())); + this.syncPersistedRows(this.mergeExpansions(this.getPersistedExpansions(), imported)); this.setSnippetStatus(i18n.get("settings_status_saved")); this.persistSnippetRows(); }); @@ -354,15 +354,6 @@ export class TextAssetsPanel { if (!nextEntry[0]) { return; } - const duplicateRow = this.snippetRows.find( - (row) => row.id !== targetRow.id && row.shortcut.trim() === nextEntry[0], - ); - if (duplicateRow) { - shortcut.setCustomValidity(i18n.get("text_assets_duplicate_shortcut")); - shortcut.reportValidity(); - updateSnippetStatus(i18n.get("text_assets_duplicate_shortcut"), true); - return; - } targetRow.shortcut = nextEntry[0]; targetRow.text = nextEntry[1]; targetRow.savedShortcut = nextEntry[0]; @@ -734,16 +725,22 @@ export class TextAssetsPanel { } private mergeExpansions( - imported: TextExpansionEntry[], existing: TextExpansionEntry[], + imported: TextExpansionEntry[], ): TextExpansionEntry[] { - const merged = new Map(); - [...existing, ...imported].forEach(([shortcut, text]) => { - if (shortcut.trim()) { - merged.set(shortcut.trim(), text); + const seen = new Set(); + return [...existing, ...imported].flatMap(([shortcut, text]) => { + const normalizedShortcut = shortcut.trim(); + if (!normalizedShortcut) { + return []; + } + const signature = JSON.stringify([normalizedShortcut, text]); + if (seen.has(signature)) { + return []; } + seen.add(signature); + return [[normalizedShortcut, text]] as TextExpansionEntry[]; }); - return Array.from(merged.entries()); } private getSelectedSnippet(): SnippetRow | null { diff --git a/tests/PresageHandler.live.test.ts b/tests/PresageHandler.live.test.ts new file mode 100644 index 00000000..bc8bc057 --- /dev/null +++ b/tests/PresageHandler.live.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import libPresageMod from "../src/third_party/libpresage/libpresage.js"; +import { PresageHandler } from "../src/adapters/chrome/background/PresageHandler"; + +function createLiveConfig(textExpansions: Array<[string, string]>) { + return { + numSuggestions: 5, + engineNumSuggestions: 10, + minWordLengthToPredict: 0, + insertSpaceAfterAutocomplete: true, + autoCapitalize: false, + textExpansions, + timeFormat: "", + dateFormat: "", + userDictionaryList: [], + }; +} + +async function createLiveHandler(): Promise { + const root = process.cwd(); + const Module = await libPresageMod({ + wasmBinary: readFileSync(`${root}/src/third_party/libpresage/libpresage.wasm`), + locateFile: (name: string) => `${root}/public/third_party/libpresage/${name}`, + }); + return new PresageHandler(Module); +} + +describe("PresageHandler live text expansion config refresh", () => { + test("refreshes duplicate text expansions after runtime config changes", async () => { + const handler = await createLiveHandler(); + + handler.setConfig(createLiveConfig([["asap", "as soon as possible"]])); + await expect(handler.runPrediction("asap", "", "textExpander")).resolves.toEqual({ + predictions: ["as soon as possible "], + }); + + handler.setConfig( + createLiveConfig([ + ["asap", "as soon as possible"], + ["asap", "at some available point"], + ]), + ); + + const refreshed = await handler.runPrediction("asap", "", "textExpander"); + + expect(refreshed.predictions).toHaveLength(2); + expect(refreshed.predictions).toEqual( + expect.arrayContaining(["as soon as possible ", "at some available point "]), + ); + }); +}); diff --git a/tests/TextAssetsPanel.test.ts b/tests/TextAssetsPanel.test.ts index 998857e1..a4344282 100644 --- a/tests/TextAssetsPanel.test.ts +++ b/tests/TextAssetsPanel.test.ts @@ -13,6 +13,7 @@ import { } from "../src/core/domain/constants"; type SettingsMap = Record; +type FileReaderCtor = typeof FileReader; class MockControl { private readonly handlers: Record void>> = {}; @@ -100,7 +101,7 @@ describe("TextAssetsPanel", () => { Settings.now = () => Date.now(); }); - test("prevents duplicate snippet shortcuts and shows an inline warning", async () => { + test("allows saving multiple snippets with the same shortcut", async () => { const values: SettingsMap = { [KEY_TEXT_EXPANSIONS]: [["brb", "be right back"]], [KEY_USER_DICTIONARY_LIST]: [], @@ -125,11 +126,83 @@ describe("TextAssetsPanel", () => { bodyInput.dispatchEvent(new Event("input", { bubbles: true })); findButtonByText(root, i18n.get("text_assets_save_snippet")).click(); + await flushAsyncWork(); + + expect(values[KEY_TEXT_EXPANSIONS]).toEqual([ + ["brb", "be right there"], + ["brb", "be right back"], + ]); + + const snippetRows = root.querySelectorAll(".text-assets-list-item"); + expect(snippetRows).toHaveLength(2); + snippetRows[0].click(); + expect((root.querySelector(".text-assets-editor textarea") as HTMLTextAreaElement).value).toBe( + "be right there", + ); + snippetRows[1].click(); + expect((root.querySelector(".text-assets-editor textarea") as HTMLTextAreaElement).value).toBe( + "be right back", + ); + }); - expect(root.textContent).toContain(i18n.get("text_assets_duplicate_shortcut")); - expect(values[KEY_TEXT_EXPANSIONS]).toEqual([["brb", "be right back"]]); - expect(shortcutInput.value).toBe("brb"); - expect(bodyInput.value).toBe("be right there"); + test("csv import deduplicates exact snippet pairs while keeping same-shortcut variants", async () => { + const values: SettingsMap = { + [KEY_TEXT_EXPANSIONS]: [["brb", "be right back"]], + [KEY_USER_DICTIONARY_LIST]: [], + [KEY_DATE_FORMAT]: "", + [KEY_TIME_FORMAT]: "", + }; + const store = createStore(values); + const registry = createRegistry(values); + const root = document.createElement("div"); + document.body.appendChild(root); + + const originalFileReader = globalThis.FileReader as FileReaderCtor; + class MockFileReader { + public result: string | ArrayBuffer | null = null; + private readonly handlers: Record void>> = {}; + + addEventListener(type: string, handler: () => void): void { + this.handlers[type] = [...(this.handlers[type] || []), handler]; + } + + readAsText(): void { + this.result = "brb,be right back\nsig,first import\nsig,first import\nsig,second import"; + for (const handler of this.handlers.load || []) { + handler(); + } + } + } + Object.assign(globalThis, { + FileReader: MockFileReader as unknown as FileReaderCtor, + }); + + try { + new TextAssetsPanel(root, registry, store); + await flushAsyncWork(); + + const importInput = root.querySelector('input[type="file"][accept=".csv"]'); + expect(importInput).not.toBeNull(); + Object.defineProperty(importInput!, "files", { + configurable: true, + value: [new File(["ignored"], "snippets.csv", { type: "text/csv" })], + }); + + importInput!.dispatchEvent(new Event("input", { bubbles: true })); + await flushAsyncWork(); + importInput!.dispatchEvent(new Event("input", { bubbles: true })); + await flushAsyncWork(); + + expect(values[KEY_TEXT_EXPANSIONS]).toEqual([ + ["brb", "be right back"], + ["sig", "first import"], + ["sig", "second import"], + ]); + } finally { + Object.assign(globalThis, { + FileReader: originalFileReader, + }); + } }); test("keeps multiple unsaved snippet drafts independently editable", async () => { diff --git a/tests/e2e/smoke.e2e.test.ts b/tests/e2e/smoke.e2e.test.ts index 4b4f10e9..6ebaa3cb 100644 --- a/tests/e2e/smoke.e2e.test.ts +++ b/tests/e2e/smoke.e2e.test.ts @@ -1471,6 +1471,48 @@ describeE2E(`E2E Smoke [${BROWSER_TYPE}]`, () => { suiteTimeout(10000, 15000), ); + test( + "text expansion popup shows duplicate shortcut entries and accepts a non-default selection", + async () => { + await setSettingAndWait(worker, KEY_ENABLED_LANGUAGES, ["textExpander"]); + await setSettingAndWait(worker, KEY_LANGUAGE, "textExpander"); + await setSettingAndWait(worker, KEY_TEXT_EXPANSIONS, [ + ["asap", "as soon as possible"], + ["asap", "at some available point"], + ]); + await sendConfigChange(browser, worker); + + page = await prepareReusableTestPage(browser, page); + + const element = await page.$("#test-input"); + await element!.type("asap"); + + const suggestions = await waitForSuggestionTexts(page); + expect(suggestions).toHaveLength(2); + expect( + suggestions.some((suggestion) => /^as soon as possible[ \xa0]$/i.test(suggestion)), + ).toBe(true); + expect( + suggestions.some((suggestion) => /^at some available point[ \xa0]$/i.test(suggestion)), + ).toBe(true); + + const selectedSuggestion = suggestions[1]; + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Tab"); + + const value = await waitUntil( + "selected duplicate text expansion to be accepted", + async () => { + const current = await page.$eval("#test-input", (el) => (el as HTMLInputElement).value); + return current === selectedSuggestion ? current : false; + }, + { timeoutMs: timeoutProfile.suggestionMs, intervalMs: 50 }, + ); + expect(value).toBe(selectedSuggestion); + }, + suiteTimeout(10000, 15000), + ); + test( "options config change command updates grammar rules in runtime storage", async () => {