feat: add preferred STT languages and fix model settings layout
This commit is contained in:
@@ -7,6 +7,7 @@ import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { createPracticeAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { sanitizeUserSettings } from "../src/core/settings.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 { sessionMarkdown } from "../src/core/session-markdown.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", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { I18nProvider, LanguageSelect, SttPreferredLanguages, useI18n } from "../components/i18n.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";
|
||||
|
||||
@@ -67,7 +67,7 @@ async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLang
|
||||
function Controls() {
|
||||
recording = useI18n().setDictating;
|
||||
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>); });
|
||||
}
|
||||
@@ -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.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 });
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("the settings store", () => {
|
||||
assert.deepEqual(out.graph, { enabled: false });
|
||||
});
|
||||
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 });
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")).general, general);
|
||||
assert.deepEqual(readSettings()?.general, general);
|
||||
|
||||
Reference in New Issue
Block a user