import React, { act } from "react"; (globalThis as Record).React = React; import assert from "node:assert/strict"; import { afterEach, it } from "node:test"; import { createRoot, type Root } from "react-dom/client"; import { parseHTML } from "linkedom"; import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx"; import { dictationUrl, translate, type GeneralSettings, type SttLanguage } from "../src/core/index.ts"; import { useEnterSubmits } from "../components/enter-submits.ts"; const dom = parseHTML(""); Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement, HTMLTextAreaElement: dom.window.HTMLTextAreaElement, CustomEvent: dom.window.CustomEvent, Node: dom.window.Node, MutationObserver: dom.window.MutationObserver, NodeFilter: { SHOW_ELEMENT: 1, FILTER_ACCEPT: 1, FILTER_SKIP: 3 }, ResizeObserver: class { observe() {} unobserve() {} disconnect() {} }, getComputedStyle: () => ({ display: "block", visibility: "visible", direction: "ltr", getPropertyValue: () => "" }), IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0), cancelAnimationFrame: (id: number) => clearTimeout(id), }); Object.defineProperty(globalThis, "navigator", { configurable: true, value: { languages: ["en-US"] } }); Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 }); const { I18nProvider, LanguageSelect, SttModelSelect, SttPreferredLanguages, useI18n } = await import("../components/i18n.tsx"); const originalFetch = globalThis.fetch; let root: Root | null = null; let host: HTMLDivElement; let general: GeneralSettings; let writes: GeneralSettings[]; let failSave = false; let holdSave: (() => Promise) | undefined; let recording: (active: boolean) => void; let resolvedSttLanguage: SttLanguage; let submits = 0; function element(selector: string): HTMLElement { const found = document.querySelector(selector); assert(found, `expected ${selector}`); return found; } function trigger(field: string, compact = false) { return element(`[data-testid="language-select-${field}${compact ? "-compact" : ""}"]`); } async function click(target: HTMLElement) { await act(async () => { target.dispatchEvent(Object.assign(new dom.window.Event("pointerdown", { bubbles: true }), { button: 0, ctrlKey: false, pointerType: "mouse", isPrimary: true })); target.dispatchEvent(new dom.window.Event("click", { bubbles: true })); }); } async function pick(text: string) { const option = [...document.querySelectorAll('[role="menuitemradio"]')].find((node) => node.textContent?.includes(text)); assert(option, `expected option ${text}`); await click(option); } async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLanguage: "en", autoUpdate: false }) { general = initialGeneral; writes = []; failSave = false; holdSave = undefined; submits = 0; globalThis.fetch = async (input, init) => { const url = String(input); if ((init?.method ?? "GET") === "GET") { assert.equal(url, "/api/settings"); return Response.json({ settings: { general } }); } assert.equal(url, "/api/settings/general"); assert.equal(init?.method, "PUT"); const next = JSON.parse(String(init.body)) as GeneralSettings; writes.push(next); await holdSave?.(); if (failSave) return new Response("failed", { status: 503 }); general = next; return Response.json({ ok: true }); }; host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); function Controls() { useEnterSubmits(() => { submits++; }); recording = useI18n().setDictating; resolvedSttLanguage = useI18n().sttLanguage; return <>; } await act(async () => { root!.render(); }); } afterEach(async () => { await act(async () => { root?.unmount(); }); root = null; host?.remove(); globalThis.fetch = originalFetch; }); it("selects only MAI or direct Deepgram, saves independently, and restores hints when returning to Deepgram", async () => { await mount({ language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en"], autoUpdate: false }); const model = () => element('[data-testid="stt-model-select"]'); assert(model().textContent?.includes("Nova-3 · Deepgram")); await click(model()); assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 2); await pick("MAI-Transcribe-2"); assert.deepEqual(general, { language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en"], autoUpdate: false, sttModel: "microsoft/mai-transcribe-2" }); assert.equal(preferredTrigger().textContent, "English, Русский▾"); assert(preferredTrigger().disabled, "unsupported hints cannot be changed for MAI"); await click(trigger("language")); await pick("English"); assert.equal(general.sttModel, "microsoft/mai-transcribe-2"); assert.deepEqual(general.sttPreferredLanguages, ["ru", "en"]); await click(model()); await pick("Nova-3 · Deepgram"); assert.equal(general.sttModel, "deepgram/nova-3"); assert(!preferredTrigger().disabled); assert.deepEqual(general.sttPreferredLanguages, ["ru", "en"]); }); it("locks model changes during recording and leaves the selected model unchanged if saving fails", async () => { await mount(); const model = () => element('[data-testid="stt-model-select"]'); await click(model()); await act(async () => recording(true)); assert(model().hasAttribute("disabled")); assert.equal(document.querySelector('[role="menu"]'), null); await act(async () => recording(false)); failSave = true; await click(model()); await pick("MAI-Transcribe-2"); assert(model().textContent?.includes("Nova-3 · Deepgram")); assert.equal(general.sttModel, undefined); assert(document.querySelector('[role="alert"]')); }); it("picks languages through the shared menu and preserves the other language and update preference", async () => { await mount(); assert.equal(document.querySelector("select"), null); await click(trigger("sttLanguage")); assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 4); assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes("English")); assert(document.querySelector('[role="menu"]')?.textContent?.includes("Язык системы")); await pick("Русский"); assert.deepEqual(general, { language: "ru", sttLanguage: "ru", autoUpdate: false }); assert(trigger("sttLanguage", true).textContent?.includes("Русский")); assert.equal(document.querySelector('[role="menu"]'), null); await click(trigger("language")); await pick("Язык системы"); assert.deepEqual(general, { sttLanguage: "ru", autoUpdate: false }); }); for (const language of ["en", "ru"] as const) it(`saves multilingual STT from both selectors and restores it on reload (${language})`, async () => { const label = translate(language, "Multilingual"); const shortLabel = translate(language, "Multi"); await mount({ language, sttLanguage: "en", autoUpdate: false }); await click(trigger("language")); assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3); assert(!document.querySelector('[role="menu"]')?.textContent?.includes(label)); await click(trigger("sttLanguage", true)); await pick(label); await click(trigger("sttLanguage", true)); assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3); assert(!document.querySelector('[role="menu"]')?.textContent?.includes(translate(language, "System default"))); await click(document.body); assert.deepEqual(general, { language, sttLanguage: "multi", autoUpdate: false }); assert.equal(resolvedSttLanguage, "multi"); assert.equal(new URL(dictationUrl(resolvedSttLanguage)).searchParams.get("language"), "multi"); assert(trigger("sttLanguage").textContent?.includes(label)); assert(trigger("sttLanguage", true).textContent?.includes(shortLabel)); const saved = general; await act(async () => root!.unmount()); root = null; host.remove(); await mount(saved); assert.equal(resolvedSttLanguage, "multi"); await click(trigger("sttLanguage")); assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes(label)); await pick(translate(language, "System default")); assert.equal(resolvedSttLanguage, "en", "system default follows the browser, independently of the interface"); assert.deepEqual(general, { language, autoUpdate: false }); await click(trigger("sttLanguage")); await pick(label); assert.deepEqual(general, { language, sttLanguage: "multi", autoUpdate: false }); }); for (const system of ["en", "ru"] as const) it(`shows the resolved system language in chat without an Auto option or an implicit save (${system})`, async () => { const browserLanguages = navigator.languages; Object.assign(navigator, { languages: [system === "ru" ? "ru-RU" : "en-US"] }); try { const label = system === "ru" ? "Русский" : "English"; await mount({ language: "ru", autoUpdate: false }); assert(trigger("sttLanguage", true).textContent?.includes(label)); assert(!trigger("sttLanguage", true).textContent?.includes("Авто")); await click(trigger("sttLanguage", true)); assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3); assert(!document.querySelector('[role="menu"]')?.textContent?.includes("Авто")); assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes(label)); assert.equal(writes.length, 0); assert.deepEqual(general, { language: "ru", autoUpdate: false }); await click(trigger("sttLanguage")); assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 4); assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes("Язык системы")); await click(trigger("sttLanguage", true)); await pick(label); assert.deepEqual(general, { language: "ru", sttLanguage: system, autoUpdate: false }); assert.equal(writes.length, 1, "an explicit pick pins the language even when it matches the system"); } finally { Object.assign(navigator, { languages: browserLanguages }); } }); it("dismisses outside clicks and Escape, and opening another selector balances menu scroll locks", async () => { await mount(); let opened = 0; const onOpen = () => opened++; const onClose = () => opened--; window.addEventListener(MENU_OPEN_EVENT, onOpen); window.addEventListener(MENU_CLOSE_EVENT, onClose); try { await click(trigger("language")); assert.equal(opened, 1); await click(trigger("sttLanguage", true)); assert.equal(opened, 1); assert.equal(trigger("language").getAttribute("aria-expanded"), "false"); await act(async () => { const event = new dom.window.Event("keydown", { bubbles: true, cancelable: true }); Object.assign(event, { key: "Escape" }); element('[role="menuitemradio"]').dispatchEvent(event); }); assert.equal(opened, 0); await click(trigger("language")); await click(document.body); assert.equal(opened, 0); assert.equal(writes.length, 0); } finally { window.removeEventListener(MENU_OPEN_EVENT, onOpen); window.removeEventListener(MENU_CLOSE_EVENT, onClose); } }); it("locks both STT selectors and closes an open menu when recording begins", async () => { await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false }); await click(trigger("sttLanguage", true)); await act(async () => recording(true)); assert.equal(document.querySelector('[role="menu"]'), null); assert((trigger("sttLanguage") as HTMLButtonElement).disabled); assert((trigger("sttLanguage", true) as HTMLButtonElement).disabled); assert(!(trigger("language") as HTMLButtonElement).disabled); await click(trigger("sttLanguage")); assert.equal(document.querySelector('[role="menu"]'), null); await act(async () => recording(false)); assert(!(trigger("sttLanguage", true) as HTMLButtonElement).disabled); assert.equal(trigger("sttLanguage", true).getAttribute("aria-expanded"), "false"); assert.equal(writes.length, 0); assert.equal(resolvedSttLanguage, "multi"); }); it("shows a localized save failure without changing the selected language and allows retry", async () => { await mount(); failSave = true; await click(trigger("sttLanguage")); await pick("Несколько языков"); assert.equal(element('[role="alert"]').textContent, translate("ru", "Could not save language settings")); assert.equal(general.sttLanguage, "en"); assert(trigger("sttLanguage").textContent?.includes("English")); failSave = false; await click(trigger("sttLanguage")); await pick("Несколько языков"); assert.equal(general.sttLanguage, "multi"); assert.equal(document.querySelector('[role="alert"]'), null); }); it("serializes rapid picks in independent selectors without losing either preference", async () => { await mount(); let release!: () => void; const pending = new Promise((resolve) => { release = resolve; }); holdSave = () => pending; await click(trigger("language")); await pick("English"); assert((trigger("language") as HTMLButtonElement).disabled); await click(trigger("sttLanguage")); await pick("Русский"); assert.equal(writes.length, 1); await act(async () => { release(); }); assert.deepEqual(general, { language: "en", sttLanguage: "ru", autoUpdate: false }); assert.equal(writes.length, 2); }); function preferred(language: "en" | "ru") { return element(`[data-testid="stt-preferred-${language}"]`); } function preferredTrigger() { return element('[data-testid="stt-preferred-select"]') as HTMLButtonElement; } async function openPreferred() { await click(preferredTrigger()); assert(document.querySelector('[data-testid="stt-preferred-menu"]')); } async function closePreferred() { await click(preferredTrigger()); assert.equal(document.querySelector('[data-testid="stt-preferred-menu"]'), null); } it("saves RU + EN hints, restores them after reload, and retains the choices in single-language mode", async () => { await mount(); assert(preferredTrigger().disabled); assert.equal(writes.length, 0); await click(trigger("sttLanguage")); await pick("Несколько языков"); assert(!preferredTrigger().disabled); await openPreferred(); await click(preferred("en")); await click(preferred("ru")); assert(document.querySelector('[data-testid="stt-preferred-menu"]'), "checkbox picks keep the dropdown open"); assert.deepEqual(general, { language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["en", "ru"], autoUpdate: false }); assert.deepEqual(new URL(dictationUrl(resolvedSttLanguage, general.sttPreferredLanguages)).searchParams.getAll("language_hint"), ["en", "ru"]); const saved = general; await act(async () => root!.unmount()); root = null; host.remove(); await mount(saved); assert(preferredTrigger().textContent?.includes("English, Русский")); await openPreferred(); assert.equal(preferred("en").getAttribute("aria-checked"), "true"); assert.equal(preferred("ru").getAttribute("aria-checked"), "true"); await closePreferred(); await click(trigger("sttLanguage", true)); await pick("English"); assert(preferredTrigger().disabled); assert.deepEqual(general.sttPreferredLanguages, ["en", "ru"]); assert.deepEqual(new URL(dictationUrl(resolvedSttLanguage, general.sttPreferredLanguages)).searchParams.getAll("language_hint"), []); await click(trigger("sttLanguage")); await pick("Несколько языков"); await openPreferred(); assert.equal(preferred("en").getAttribute("aria-checked"), "true"); await click(preferred("en")); await click(preferred("ru")); assert.deepEqual(general.sttPreferredLanguages, []); assert(preferredTrigger().textContent?.includes("Без предпочтений")); }); it("locks preference edits while saving and recording, and reports a failed save without changing the choice", async () => { await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false }); await openPreferred(); failSave = true; await click(preferred("en")); assert.equal(preferred("en").getAttribute("aria-checked"), "false"); assert.equal(general.sttPreferredLanguages, undefined); assert.equal(element('[role="alert"]').textContent, "Не удалось сохранить предпочитаемые языки"); failSave = false; let release!: () => void; const pending = new Promise((resolve) => { release = resolve; }); holdSave = () => pending; await click(preferred("en")); assert.equal(preferred("en").getAttribute("aria-disabled"), "true"); assert.equal(preferred("ru").getAttribute("aria-disabled"), "true"); await click(preferred("ru")); assert.equal(writes.length, 2); await act(async () => release()); holdSave = undefined; assert.equal(element('[data-testid="stt-preferred-en"]').getAttribute("aria-checked"), "true"); assert.equal(document.querySelector('[role="alert"]'), null); await act(async () => recording(true)); assert(preferredTrigger().disabled); assert.equal(document.querySelector('[data-testid="stt-preferred-menu"]'), null); await click(preferredTrigger()); assert.deepEqual(general.sttPreferredLanguages, ["en"]); await act(async () => recording(false)); assert(!preferredTrigger().disabled); }); it("serializes hint and interface-language saves without losing either preference", async () => { await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false }); await openPreferred(); let release!: () => void; const pending = new Promise((resolve) => { release = resolve; }); holdSave = () => pending; await click(preferred("ru")); await click(trigger("language")); await pick("English"); await act(async () => release()); assert.deepEqual(general, { language: "en", sttLanguage: "multi", sttPreferredLanguages: ["ru"], autoUpdate: false }); }); it("keeps keyboard checkbox selection inside the menu without submitting the underlying composer", async () => { await mount({ language: "en", sttLanguage: "multi" }); assert(preferredTrigger().textContent?.includes("No preference")); await openPreferred(); await act(async () => preferred("en").dispatchEvent(Object.assign(new dom.window.Event("keydown", { bubbles: true, cancelable: true }), { key: "Enter" }))); assert.deepEqual(general.sttPreferredLanguages, ["en"]); assert.equal(submits, 0); assert(document.querySelector('[data-testid="stt-preferred-menu"]')); });