2026-10-25 21:19:48 +03:00
|
|
|
import React, { act } from "react";
|
|
|
|
|
(globalThis as Record<string, unknown>).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 { I18nProvider, LanguageSelect, useI18n } from "../components/i18n.tsx";
|
|
|
|
|
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx";
|
2026-10-26 00:58:25 +03:00
|
|
|
import { dictationUrl, translate, type GeneralSettings, type SttLanguage } from "../src/core/index.ts";
|
2026-10-25 21:19:48 +03:00
|
|
|
|
|
|
|
|
const dom = parseHTML("<html><body></body></html>");
|
|
|
|
|
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,
|
|
|
|
|
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 originalFetch = globalThis.fetch;
|
|
|
|
|
let root: Root | null = null;
|
|
|
|
|
let host: HTMLDivElement;
|
|
|
|
|
let general: GeneralSettings;
|
|
|
|
|
let writes: GeneralSettings[];
|
|
|
|
|
let failSave = false;
|
|
|
|
|
let holdSave: (() => Promise<void>) | undefined;
|
|
|
|
|
let recording: (active: boolean) => void;
|
2026-10-26 00:58:25 +03:00
|
|
|
let resolvedSttLanguage: SttLanguage;
|
2026-10-25 21:19:48 +03:00
|
|
|
|
|
|
|
|
function element(selector: string): HTMLElement {
|
|
|
|
|
const found = document.querySelector<HTMLElement>(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(new dom.window.Event("pointerdown", { bubbles: true }));
|
|
|
|
|
target.dispatchEvent(new dom.window.Event("click", { bubbles: true }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
async function pick(text: string) {
|
|
|
|
|
const option = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find((node) => node.textContent?.includes(text));
|
|
|
|
|
assert(option, `expected option ${text}`);
|
|
|
|
|
await click(option);
|
|
|
|
|
}
|
2026-10-26 00:58:25 +03:00
|
|
|
async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLanguage: "en", autoUpdate: false }) {
|
|
|
|
|
general = initialGeneral;
|
2026-10-25 21:19:48 +03:00
|
|
|
writes = []; failSave = false; holdSave = undefined;
|
|
|
|
|
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() {
|
|
|
|
|
recording = useI18n().setDictating;
|
2026-10-26 00:58:25 +03:00
|
|
|
resolvedSttLanguage = useI18n().sttLanguage;
|
2026-10-25 21:19:48 +03:00
|
|
|
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /></>;
|
|
|
|
|
}
|
|
|
|
|
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
|
|
|
|
|
}
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await act(async () => { root?.unmount(); }); root = null; host?.remove(); globalThis.fetch = originalFetch;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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"));
|
2026-10-26 00:58:25 +03:00
|
|
|
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 4);
|
2026-10-25 21:19:48 +03:00
|
|
|
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 });
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-26 00:58:25 +03:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-25 21:19:48 +03:00
|
|
|
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 () => {
|
2026-10-26 00:58:25 +03:00
|
|
|
await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false }); await click(trigger("sttLanguage", true));
|
2026-10-25 21:19:48 +03:00
|
|
|
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);
|
2026-10-26 00:58:25 +03:00
|
|
|
assert.equal(resolvedSttLanguage, "multi");
|
2026-10-25 21:19:48 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows a localized save failure without changing the selected language and allows retry", async () => {
|
|
|
|
|
await mount(); failSave = true;
|
2026-10-26 00:58:25 +03:00
|
|
|
await click(trigger("sttLanguage")); await pick("Несколько языков");
|
2026-10-25 21:19:48 +03:00
|
|
|
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;
|
2026-10-26 00:58:25 +03:00
|
|
|
await click(trigger("sttLanguage")); await pick("Несколько языков");
|
|
|
|
|
assert.equal(general.sttLanguage, "multi"); assert.equal(document.querySelector('[role="alert"]'), null);
|
2026-10-25 21:19:48 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("serializes rapid picks in independent selectors without losing either preference", async () => {
|
|
|
|
|
await mount(); let release!: () => void;
|
|
|
|
|
const pending = new Promise<void>((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);
|
|
|
|
|
});
|