feat: add preferred STT languages and fix model settings layout

This commit is contained in:
2026-10-26 02:14:27 +03:00
parent 70977bad84
commit ce28d95bcf
13 changed files with 195 additions and 50 deletions
+8 -7
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2Icon, MicIcon } from "lucide-react"; import { Loader2Icon, MicIcon } from "lucide-react";
import { useSettingsDialog } from "@/components/settings/settings-dialog"; import { useSettingsDialog } from "@/components/settings/settings-dialog";
import { dictationUrl, type SttLanguage } from "@/src/core"; import { dictationUrl } from "@/src/core";
import { LanguageSelect, useI18n } from "@/components/i18n"; import { LanguageSelect, useI18n } from "@/components/i18n";
import { Hint } from "@/components/ui/hint"; import { Hint } from "@/components/ui/hint";
@@ -279,10 +279,10 @@ export type Dictation = {
* question waits. * question waits.
*/ */
export function useDictation({ append }: { append: (text: string) => void }): Dictation { export function useDictation({ append }: { append: (text: string) => void }): Dictation {
const { sttLanguage, setDictating } = useI18n(); const { sttLanguage, general, setDictating } = useI18n();
const selectedLanguage = useRef(sttLanguage); const selectedUrl = useRef("");
selectedLanguage.current = sttLanguage; selectedUrl.current = dictationUrl(sttLanguage, general.sttPreferredLanguages);
const recordingLanguage = useRef<SttLanguage>(sttLanguage); const recordingUrl = useRef(selectedUrl.current);
const [phase, setPhase] = useState<DictationPhase>("idle"); const [phase, setPhase] = useState<DictationPhase>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */ /** Latest RMS from the worklet — the meter samples this (dictate pattern). */
@@ -365,7 +365,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
// Minted JWTs authenticate via the "bearer" subprotocol — ["token", // Minted JWTs authenticate via the "bearer" subprotocol — ["token",
// jwt] is rejected at the handshake (verified live; "token" is for // jwt] is rejected at the handshake (verified live; "token" is for
// raw API keys only, as in the owner's pi dictate extension). // raw API keys only, as in the owner's pi dictate extension).
const ws = new WebSocket(dictationUrl(recordingLanguage.current), ["bearer", token]); const ws = new WebSocket(recordingUrl.current, ["bearer", token]);
wsRef.current = ws; wsRef.current = ws;
ws.addEventListener("open", () => { ws.addEventListener("open", () => {
@@ -425,7 +425,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
); );
const start = useCallback(async () => { const start = useCallback(async () => {
recordingLanguage.current = selectedLanguage.current; // Pin the complete configuration once; reconnects must retain both language and hints.
recordingUrl.current = selectedUrl.current;
setDictating(true); setDictating(true);
dictationActiveFlag = true; dictationActiveFlag = true;
setError(null); setError(null);
+30 -1
View File
@@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useR
import { resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type Translator } from "@/src/core"; import { resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type Translator } from "@/src/core";
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal"; import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
import { MenuShell } from "./menu-shell"; import { MenuShell } from "./menu-shell";
import { CheckIcon } from "lucide-react";
type LanguageField = "language" | "sttLanguage"; type LanguageField = "language" | "sttLanguage";
type I18nValue = { type I18nValue = {
@@ -57,7 +58,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: {
if (!current.ok) throw new Error("Could not load language settings"); if (!current.ok) throw new Error("Could not load language settings");
const body = await current.json() as { settings?: { general?: GeneralSettings } }; const body = await current.json() as { settings?: { general?: GeneralSettings } };
const next = { ...(body.settings?.general ?? latest.current) }; const next = { ...(body.settings?.general ?? latest.current) };
for (const field of ["language", "sttLanguage", "autoUpdate"] as const) { for (const field of ["language", "sttLanguage", "sttPreferredLanguages", "autoUpdate"] as const) {
if (!Object.hasOwn(patch, field)) continue; if (!Object.hasOwn(patch, field)) continue;
if (patch[field] === undefined) delete next[field]; if (patch[field] === undefined) delete next[field];
else Object.assign(next, { [field]: patch[field] }); else Object.assign(next, { [field]: patch[field] });
@@ -148,3 +149,31 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>} {error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
</span>; </span>;
} }
/** Preferred languages guide multilingual recognition; they are saved independently of its mode. */
export function SttPreferredLanguages() {
const { general, sttLanguage, dictating, saveGeneral, t } = useI18n();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const preferred = general.sttPreferredLanguages ?? [];
const locked = sttLanguage !== "multi" || dictating || saving;
const toggle = (language: Language) => {
if (locked) return;
const next = (["en", "ru"] as const).filter((code) => code === language ? !preferred.includes(code) : preferred.includes(code));
setSaving(true); setError(null);
void saveGeneral({ sttPreferredLanguages: next })
.catch(() => setError("Could not save preferred languages"))
.finally(() => setSaving(false));
};
return <span className="flex max-w-full flex-col items-start gap-1">
<span role="group" aria-label={t("Preferred languages")} className="flex max-w-full flex-wrap gap-2">
{(["en", "ru"] as const).map((language) => <button key={language} type="button" role="checkbox" aria-checked={preferred.includes(language)}
data-testid={`stt-preferred-${language}`} disabled={locked} onClick={() => toggle(language)}
className={`flex h-7 items-center gap-1.5 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${preferred.includes(language) ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50"}`}>
<span aria-hidden className="flex size-3.5 shrink-0 items-center justify-center rounded-xs border border-border">{preferred.includes(language) && <CheckIcon className="size-3" />}</span>
{language === "en" ? "English" : "Русский"}
</button>)}
</span>
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
</span>;
}
+7 -5
View File
@@ -666,15 +666,15 @@ export function AgentSection({
label={SLOT_META[slot].label} label={SLOT_META[slot].label}
hint={SLOT_META[slot].roster} hint={SLOT_META[slot].roster}
testid={`slot-row-${slot}`} testid={`slot-row-${slot}`}
className="flex-wrap gap-y-2" className="flex-wrap items-start gap-y-2 [&>span:first-child]:flex-[1_1_12rem]"
> >
{/* The anchor for the outside-click contract is the {/* The anchor for the outside-click contract is the
span holding BOTH triggers of the open menu. */} span holding the tier's controls. */}
<span <span
ref={thisMenuOpen ? menuAnchorRef : undefined} ref={thisMenuOpen ? menuAnchorRef : undefined}
className="relative ml-auto flex max-w-full flex-wrap items-center justify-end gap-2" className="relative flex w-68 max-w-full shrink-0 flex-col items-start gap-2"
> >
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>} <span data-testid={`slot-controls-${slot}`} className="grid w-full grid-cols-[minmax(0,1fr)_5rem_auto] items-center gap-2">
<MenuTrigger <MenuTrigger
value={modelId ? prettyModel(modelId) : ""} value={modelId ? prettyModel(modelId) : ""}
emptyLabel="—" emptyLabel="—"
@@ -682,7 +682,7 @@ export function AgentSection({
testid={`slot-model-${slot}`} testid={`slot-model-${slot}`}
open={thisMenuOpen === "model"} open={thisMenuOpen === "model"}
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })} onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
widthClass="min-w-20" widthClass="w-full min-w-0"
disabled={savingModels} disabled={savingModels}
/> />
<MenuTrigger <MenuTrigger
@@ -709,6 +709,8 @@ export function AgentSection({
</button> </button>
</span> </span>
</Hint> </Hint>
</span>
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
{thisMenuOpen === "model" && ( {thisMenuOpen === "model" && (
<MenuShell <MenuShell
preferredSide="down" preferredSide="down"
+4 -1
View File
@@ -39,7 +39,7 @@ import {
type GraphSettings, type GraphSettings,
type UserSettings, type UserSettings,
} from "@/src/core"; } from "@/src/core";
import { LanguageSelect, useI18n } from "@/components/i18n"; import { LanguageSelect, SttPreferredLanguages, useI18n } from "@/components/i18n";
import { useAppearance } from "./appearance.tsx"; import { useAppearance } from "./appearance.tsx";
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx"; import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
import { AgentSection } from "./agent-section.tsx"; import { AgentSection } from "./agent-section.tsx";
@@ -368,6 +368,9 @@ export function SettingsDialogBody({
> >
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow> <SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
<SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow> <SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
<SettingsRow label={t("Preferred languages")} hint={t("Used with Multilingual. Favors selected languages; other languages may still be recognized.")} testid="row-stt-preferred-languages" className="flex-col items-start gap-2">
<SttPreferredLanguages />
</SettingsRow>
<SettingsRow <SettingsRow
label={t("Auto-update")} label={t("Auto-update")}
hint={AUTO_UPDATE_HINT} hint={AUTO_UPDATE_HINT}
+4 -1
View File
@@ -63,8 +63,11 @@ export function learningLanguageInstruction(language: Language): string {
: "LANGUAGE: Use English for all learner-facing content, including teaching, questions and choices, plans, titles, practice, research summaries, knowledge-graph propositions, and text in generated diagrams, figures and demos. Preserve code, notation, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary."; : "LANGUAGE: Use English for all learner-facing content, including teaching, questions and choices, plans, titles, practice, research summaries, knowledge-graph propositions, and text in generated diagrams, figures and demos. Preserve code, notation, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary.";
} }
/** Streaming configuration: the language is pinned once at recording start, including reconnects. */ /** Streaming configuration: the language is pinned once at recording start, including reconnects. */
export function dictationUrl(language: SttLanguage): string { export function dictationUrl(language: SttLanguage, preferredLanguages: readonly Language[] = []): string {
const params = new URLSearchParams({ model: "nova-3", language, encoding: "linear16", sample_rate: "16000", channels: "1", smart_format: "true", punctuate: "true", endpointing: "300" }); const params = new URLSearchParams({ model: "nova-3", language, encoding: "linear16", sample_rate: "16000", channels: "1", smart_format: "true", punctuate: "true", endpointing: "300" });
if (language === "multi") {
for (const preferred of new Set(preferredLanguages)) if (isLanguage(preferred)) params.append("language_hint", preferred);
}
return `wss://api.deepgram.com/v1/listen?${params}`; return `wss://api.deepgram.com/v1/listen?${params}`;
} }
+4
View File
@@ -251,6 +251,10 @@
"System default": "System default", "System default": "System default",
"Multilingual": "Multilingual", "Multilingual": "Multilingual",
"Multi": "Multi", "Multi": "Multi",
"Preferred languages": "Preferred languages",
"Used with Multilingual. Favors selected languages; other languages may still be recognized.": "Used with Multilingual. Favors selected languages; other languages may still be recognized.",
"Could not save preferred languages": "Could not save preferred languages",
"sttPreferredLanguages must be an array of \"en\" and \"ru\"": "sttPreferredLanguages must be an array of \"en\" and \"ru\"",
"Global": "Global", "Global": "Global",
"Chat": "Chat", "Chat": "Chat",
"Graph view": "Graph view", "Graph view": "Graph view",
+4
View File
@@ -251,6 +251,10 @@
"System default": "Язык системы", "System default": "Язык системы",
"Multilingual": "Несколько языков", "Multilingual": "Несколько языков",
"Multi": "Мульти", "Multi": "Мульти",
"Preferred languages": "Предпочитаемые языки",
"Used with Multilingual. Favors selected languages; other languages may still be recognized.": "Используется в режиме «Несколько языков». Выбранные языки получают приоритет, но могут распознаваться и другие.",
"Could not save preferred languages": "Не удалось сохранить предпочитаемые языки",
"sttPreferredLanguages must be an array of \"en\" and \"ru\"": "Предпочитаемые языки должны быть списком кодов «en» и «ru»",
"Global": "Общие", "Global": "Общие",
"Chat": "Занятие", "Chat": "Занятие",
"Graph view": "Граф", "Graph view": "Граф",
+1
View File
@@ -184,6 +184,7 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate, autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
...(record?.general?.language ? { language: record.general.language } : {}), ...(record?.general?.language ? { language: record.general.language } : {}),
...(record?.general?.sttLanguage ? { sttLanguage: record.general.sttLanguage } : {}), ...(record?.general?.sttLanguage ? { sttLanguage: record.general.sttLanguage } : {}),
...(record?.general?.sttPreferredLanguages ? { sttPreferredLanguages: [...record.general.sttPreferredLanguages] } : {}),
}, },
agent: { agent: {
models, models,
+3
View File
@@ -240,6 +240,8 @@ export interface GeneralSettings {
language?: Language; language?: Language;
/** Independent dictation language; multi = multilingual, absent = browser/system language. */ /** Independent dictation language; multi = multilingual, absent = browser/system language. */
sttLanguage?: SttLanguage; sttLanguage?: SttLanguage;
/** Optional recognition hints for multilingual dictation; absent/empty = automatic detection. */
sttPreferredLanguages?: Language[];
} }
/** Agent settings — everything about the non-conversation machinery. */ /** Agent settings — everything about the non-conversation machinery. */
@@ -450,6 +452,7 @@ function sanitizeGeneral(raw: Record<string, unknown>): GeneralSettings {
if (autoUpdate !== undefined) out.autoUpdate = autoUpdate; if (autoUpdate !== undefined) out.autoUpdate = autoUpdate;
if (isLanguage(raw.language)) out.language = raw.language; if (isLanguage(raw.language)) out.language = raw.language;
if (isSttLanguage(raw.sttLanguage)) out.sttLanguage = raw.sttLanguage; if (isSttLanguage(raw.sttLanguage)) out.sttLanguage = raw.sttLanguage;
if (Array.isArray(raw.sttPreferredLanguages)) out.sttPreferredLanguages = [...new Set(raw.sttPreferredLanguages.filter(isLanguage))];
return out; return out;
} }
+4
View File
@@ -94,6 +94,10 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
if (!isSttLanguage(raw.sttLanguage)) return { ok: false, error: 'sttLanguage must be "en", "ru", or "multi"' }; if (!isSttLanguage(raw.sttLanguage)) return { ok: false, error: 'sttLanguage must be "en", "ru", or "multi"' };
settings.sttLanguage = raw.sttLanguage; settings.sttLanguage = raw.sttLanguage;
} }
if (raw.sttPreferredLanguages !== undefined) {
if (!Array.isArray(raw.sttPreferredLanguages) || !raw.sttPreferredLanguages.every(isLanguage)) return { ok: false, error: 'sttPreferredLanguages must be an array of "en" and "ru"' };
settings.sttPreferredLanguages = [...new Set(raw.sttPreferredLanguages)];
}
return { ok: true, settings }; return { ok: true, settings };
} }
+30
View File
@@ -7,6 +7,7 @@ import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
import { createPracticeAgent } from "../src/agent/teacher-agent.ts"; import { createPracticeAgent } from "../src/agent/teacher-agent.ts";
import { sanitizeUserSettings } from "../src/core/settings.ts"; import { sanitizeUserSettings } from "../src/core/settings.ts";
import { parseGeneralSettingsBody } from "../src/server/settings-body.ts"; import { parseGeneralSettingsBody } from "../src/server/settings-body.ts";
import { resolveSettings } from "../src/core/settings-resolution.ts";
import { countLabel, dictationUrl, resolveLanguage, resolveSttLanguage, systemLanguage, translate } from "../src/core/i18n.ts"; import { countLabel, dictationUrl, resolveLanguage, resolveSttLanguage, systemLanguage, translate } from "../src/core/i18n.ts";
import { sessionMarkdown } from "../src/core/session-markdown.ts"; import { sessionMarkdown } from "../src/core/session-markdown.ts";
import type { LhMessage } from "../src/core/native-messages.ts"; import type { LhMessage } from "../src/core/native-messages.ts";
@@ -68,6 +69,35 @@ describe("language preferences", () => {
}); });
} }
}); });
it("validates and resolves preferred speech languages without changing the recognition mode", () => {
const general = { language: "ru", sttLanguage: "multi", sttPreferredLanguages: ["ru", "en", "ru"], autoUpdate: false };
const expected = { ...general, sttPreferredLanguages: ["ru", "en"] };
const parsed = parseGeneralSettingsBody(general);
assert.deepEqual(parsed, { ok: true, settings: expected });
const record = sanitizeUserSettings({ version: 1, general });
assert.deepEqual(record?.general, expected);
assert.deepEqual(resolveSettings(record).general, expected);
assert.deepEqual(parseGeneralSettingsBody({ sttPreferredLanguages: [] }), { ok: true, settings: { sttPreferredLanguages: [] } });
for (const invalid of [null, "en", ["de"], ["multi"], ["en", 1], {}]) {
const result = parseGeneralSettingsBody({ sttPreferredLanguages: invalid });
assert(!result.ok);
assert.equal(translate("ru", result.error), "Предпочитаемые языки должны быть списком кодов «en» и «ru»");
}
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { sttPreferredLanguages: ["ru", "de", "en", null, "ru"] } })?.general, { sttPreferredLanguages: ["ru", "en"] });
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { sttPreferredLanguages: "ru" } })?.general, {});
});
it("sends repeated language hints only for multilingual recognition, with no hints by default", () => {
assert.deepEqual(new URL(dictationUrl("multi")).searchParams.getAll("language_hint"), []);
assert.deepEqual(new URL(dictationUrl("multi", [])).searchParams.getAll("language_hint"), []);
const url = new URL(dictationUrl("multi", ["en", "ru", "en"]));
assert.deepEqual(url.searchParams.getAll("language_hint"), ["en", "ru"]);
assert.equal(url.searchParams.get("language"), "multi");
url.searchParams.delete("language_hint");
assert.equal(url.toString(), dictationUrl("multi"), "hinting must preserve every existing streaming parameter");
for (const language of ["en", "ru"] as const) {
assert.equal(dictationUrl(language, ["en", "ru"]), dictationUrl(language));
}
});
}); });
describe("translation quality and rendering", () => { describe("translation quality and rendering", () => {
+63 -2
View File
@@ -4,7 +4,7 @@ import assert from "node:assert/strict";
import { afterEach, it } from "node:test"; import { afterEach, it } from "node:test";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { parseHTML } from "linkedom"; import { parseHTML } from "linkedom";
import { I18nProvider, LanguageSelect, useI18n } from "../components/i18n.tsx"; import { I18nProvider, LanguageSelect, SttPreferredLanguages, useI18n } from "../components/i18n.tsx";
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx"; 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 { dictationUrl, translate, type GeneralSettings, type SttLanguage } from "../src/core/index.ts";
@@ -67,7 +67,7 @@ async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLang
function Controls() { function Controls() {
recording = useI18n().setDictating; recording = useI18n().setDictating;
resolvedSttLanguage = useI18n().sttLanguage; resolvedSttLanguage = useI18n().sttLanguage;
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /></>; return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /><SttPreferredLanguages /></>;
} }
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); }); await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
} }
@@ -201,3 +201,64 @@ it("serializes rapid picks in independent selectors without losing either prefer
assert.deepEqual(general, { language: "en", sttLanguage: "ru", autoUpdate: false }); assert.deepEqual(general, { language: "en", sttLanguage: "ru", autoUpdate: false });
assert.equal(writes.length, 2); assert.equal(writes.length, 2);
}); });
function preferred(language: "en" | "ru") { return element(`[data-testid="stt-preferred-${language}"]`) as HTMLButtonElement; }
it("saves RU + EN hints, restores them after reload, and retains the choices in single-language mode", async () => {
await mount();
assert(preferred("en").disabled); assert(preferred("ru").disabled);
assert.equal(writes.length, 0);
await click(trigger("sttLanguage")); await pick("Несколько языков");
assert(!preferred("en").disabled);
await click(preferred("en")); await click(preferred("ru"));
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.equal(preferred("en").getAttribute("aria-checked"), "true");
assert.equal(preferred("ru").getAttribute("aria-checked"), "true");
await click(trigger("sttLanguage", true)); await pick("English");
assert(preferred("en").disabled); assert(preferred("ru").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("Несколько языков");
assert.equal(preferred("en").getAttribute("aria-checked"), "true");
await click(preferred("en")); await click(preferred("ru"));
assert.deepEqual(general.sttPreferredLanguages, []);
});
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 });
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<void>((resolve) => { release = resolve; }); holdSave = () => pending;
await click(preferred("en"));
assert(preferred("en").disabled); assert(preferred("ru").disabled);
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(preferred("en").disabled); assert(preferred("ru").disabled);
await click(preferred("en"));
assert.deepEqual(general.sttPreferredLanguages, ["en"]);
await act(async () => recording(false));
assert(!preferred("en").disabled);
});
it("serializes hint and interface-language saves without losing either preference", async () => {
await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false });
let release!: () => void;
const pending = new Promise<void>((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 });
});
+1 -1
View File
@@ -49,7 +49,7 @@ describe("the settings store", () => {
assert.deepEqual(out.graph, { enabled: false }); assert.deepEqual(out.graph, { enabled: false });
}); });
it("persists multilingual STT independently of the interface language and update preference", () => { it("persists multilingual STT independently of the interface language and update preference", () => {
const general = { language: "ru", sttLanguage: "multi", autoUpdate: false } as const; const general = { language: "ru" as const, sttLanguage: "multi" as const, sttPreferredLanguages: ["en" as const, "ru" as const], autoUpdate: false };
writeSettings({ version: 1, general }); writeSettings({ version: 1, general });
assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")).general, general); assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")).general, general);
assert.deepEqual(readSettings()?.general, general); assert.deepEqual(readSettings()?.general, general);