186 lines
12 KiB
TypeScript
186 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import { createElement } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { createFauxCore } from "@earendil-works/pi-ai";
|
|
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";
|
|
import { I18nProvider, LanguageSelect } from "../components/i18n.tsx";
|
|
import { KnowledgeSummary } from "../components/minimal-home.tsx";
|
|
import { AbortTurnBody } from "../components/abort-turn-dialog.tsx";
|
|
import en from "../src/core/locales/en.json";
|
|
import ru from "../src/core/locales/ru.json";
|
|
import { createRequire } from "node:module";
|
|
const require = createRequire(import.meta.url);
|
|
const native = require("../electron/language.cjs");
|
|
|
|
describe("language preferences", () => {
|
|
it("uses the preferred browser/system language and English for unsupported languages", () => {
|
|
assert.equal(systemLanguage("ru-RU,ru;q=0.9,en;q=0.8"), "ru");
|
|
assert.equal(systemLanguage("en;q=0.4,ru;q=0.9"), "ru");
|
|
assert.equal(systemLanguage("ru;q=0,en;q=1"), "en");
|
|
assert.equal(systemLanguage(["de-DE", "ru"]), "en");
|
|
assert.equal(systemLanguage(undefined), "en");
|
|
assert.equal(resolveLanguage("en", "ru-RU"), "en");
|
|
assert.equal(resolveLanguage("ru", "en-US"), "ru");
|
|
});
|
|
it("resolves multilingual only for STT and keeps system defaults independent", () => {
|
|
assert.equal(resolveSttLanguage("multi", "ru-RU"), "multi");
|
|
assert.equal(resolveSttLanguage("multi", "en-US"), "multi");
|
|
assert.equal(resolveLanguage("multi", "ru-RU"), "ru");
|
|
assert.equal(resolveSttLanguage(undefined, "ru-RU"), "ru");
|
|
assert.equal(resolveSttLanguage(undefined, "de-DE"), "en");
|
|
assert.equal(resolveSttLanguage("en", "ru-RU"), "en");
|
|
assert.equal(resolveSttLanguage("ru", "en-US"), "ru");
|
|
});
|
|
it("validates both independent preferences without changing existing general options", () => {
|
|
const general = { autoUpdate: false, language: "ru", sttLanguage: "en" };
|
|
assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general });
|
|
assert.deepEqual(sanitizeUserSettings({ version: 1, general })?.general, general);
|
|
assert.deepEqual(parseGeneralSettingsBody({}), { ok: true, settings: {} });
|
|
assert.equal(parseGeneralSettingsBody({ language: "auto" }).ok, false);
|
|
assert.equal(parseGeneralSettingsBody({ sttLanguage: "fr" }).ok, false);
|
|
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { language: null, sttLanguage: "fr", autoUpdate: false } })?.general, { autoUpdate: false });
|
|
});
|
|
it("accepts a multilingual STT preference without allowing a multilingual interface", () => {
|
|
const general = { autoUpdate: false, language: "ru", sttLanguage: "multi" };
|
|
assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general });
|
|
assert.deepEqual(sanitizeUserSettings({ version: 1, general })?.general, general);
|
|
assert.equal(parseGeneralSettingsBody({ language: "multi" }).ok, false);
|
|
assert.deepEqual(sanitizeUserSettings({ version: 1, general: { language: "multi", sttLanguage: "multi" } })?.general, { sttLanguage: "multi" });
|
|
const invalid = parseGeneralSettingsBody({ sttLanguage: "auto" });
|
|
assert(!invalid.ok);
|
|
assert.equal(translate("ru", invalid.error), "Параметр sttLanguage должен иметь значение «en», «ru» или «multi»");
|
|
});
|
|
it("pins an explicit supported language in the STT provider request", () => {
|
|
for (const language of ["en", "ru", "multi"] as const) {
|
|
const url = new URL(dictationUrl(language));
|
|
assert.equal(url.protocol, "wss:");
|
|
assert.equal(url.hostname, "api.deepgram.com");
|
|
assert.deepEqual(Object.fromEntries(url.searchParams), {
|
|
model: "nova-3", language, encoding: "linear16", sample_rate: "16000",
|
|
channels: "1", smart_format: "true", punctuate: "true", endpointing: "300",
|
|
});
|
|
}
|
|
});
|
|
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", () => {
|
|
it("keeps catalogs complete and parameter names valid", () => {
|
|
assert.deepEqual(Object.keys(en).sort(), Object.keys(ru).sort());
|
|
for (const [key, value] of Object.entries(ru)) {
|
|
const variants = typeof value === "string" ? [value] : Object.values(value);
|
|
assert(variants.length > 0, key);
|
|
for (const text of variants) {
|
|
assert(text.trim(), key);
|
|
for (const placeholder of text.matchAll(/\{(\w+)\}/g)) assert(key.includes(placeholder[0]), `${key}: unexpected ${placeholder[0]}`);
|
|
}
|
|
}
|
|
});
|
|
it("uses Russian plural forms including 11 and 21 while retaining English wording", () => {
|
|
for (const [count, expected] of [[1, "1 утверждение"], [2, "2 утверждения"], [5, "5 утверждений"], [11, "11 утверждений"], [21, "21 утверждение"], [22, "22 утверждения"], [25, "25 утверждений"]] as const) {
|
|
assert.equal(countLabel("ru", count, "proposition", "propositions"), expected);
|
|
}
|
|
assert.equal(countLabel("en", 1, "proposition", "propositions"), "1 proposition");
|
|
assert.equal(countLabel("en", 5, "proposition", "propositions"), "5 propositions");
|
|
assert.equal(translate("en", "· read {count} pages", { count: 1 }), "· read 1 page");
|
|
});
|
|
it("localizes the approved background-job completion wording", () => {
|
|
assert.equal(translate("en", "finished"), "finished");
|
|
assert.equal(translate("ru", "finished"), "завершено");
|
|
assert.equal(translate("ru", "failed"), "ошибка");
|
|
});
|
|
it("localizes canonical protocol errors without changing provider diagnostics or parameters", () => {
|
|
assert.equal(translate("ru", "No OpenAI key is set — add one in Settings → Keys"), "Ключ OpenAI не задан — добавьте его в разделе Настройки → Провайдеры");
|
|
assert.equal(translate("ru", "Save failed (503)"), "Не удалось сохранить (503)");
|
|
assert.equal(translate("ru", JSON.stringify({ error: "Unknown chat" })), "Занятие не найдено");
|
|
assert.equal(translate("ru", "Provider diagnostic 9f1c"), "Provider diagnostic 9f1c");
|
|
assert.equal(translate("ru", "Select node {v0}", { v0: "English <title>" }), "Выбрать узел «English <title>»");
|
|
});
|
|
it("renders Russian and English concurrently without a shared mutable locale", () => {
|
|
const summary = createElement(KnowledgeSummary, { propositions: 22, chains: 5, teachSessions: 1, practiceSessions: 2, importedNotes: 11 });
|
|
const russian = renderToStaticMarkup(<I18nProvider system="ru">{summary}</I18nProvider>);
|
|
const english = renderToStaticMarkup(<I18nProvider system="en">{summary}</I18nProvider>);
|
|
assert(russian.includes("22 утверждения"));
|
|
assert(russian.includes("11 импортированных заметок"));
|
|
assert(english.includes("22 propositions"));
|
|
assert(!english.includes("утверждения"));
|
|
});
|
|
it("offers independent saved language choices with localized accessible labels", () => {
|
|
const html = renderToStaticMarkup(<I18nProvider initialGeneral={{ language: "ru", sttLanguage: "en" }}><LanguageSelect field="sttLanguage" /></I18nProvider>);
|
|
assert(html.includes('aria-label="Язык распознавания речи"'));
|
|
assert(html.includes('aria-haspopup="menu"'));
|
|
assert(html.includes('aria-expanded="false"'));
|
|
assert(html.includes("English"));
|
|
});
|
|
it("translates dialog text and accessibility labels", () => {
|
|
const html = renderToStaticMarkup(<I18nProvider system="ru"><AbortTurnBody mode="turn" onClose={() => {}} onConfirm={() => {}} /></I18nProvider>);
|
|
assert(html.includes('aria-label="Прервать ответ?"'));
|
|
assert(html.includes("Частичный ответ останется в истории занятия."));
|
|
assert(!html.includes("Abort this turn?"));
|
|
});
|
|
it("exports Russian headings and feedback while preserving authored content", () => {
|
|
const messages: LhMessage[] = [
|
|
{ role: "user", id: "u", content: "Original English text", timestamp: 1 },
|
|
{ role: "toolResult", id: "t", toolName: "quiz", toolCallId: "q", content: [], details: { questionText: "Original question", correct: true }, timestamp: 2, isError: false },
|
|
] as LhMessage[];
|
|
const markdown = sessionMarkdown(messages, { anchors: false, language: "ru" });
|
|
assert(markdown.includes("### Учащийся"));
|
|
assert(markdown.includes("Original English text"));
|
|
assert(markdown.includes("Original question"));
|
|
assert(markdown.includes("Учащийся ответил верно."));
|
|
});
|
|
it("supplies language instructions to rebuilt teaching and practice agents", () => {
|
|
const faux = createFauxCore({});
|
|
const engine = { model: faux.getModel(), streamFn: faux.streamSimple };
|
|
const teacher = createTeacherAgent(undefined, { engine, tools: {}, language: "ru" });
|
|
assert(teacher.state.systemPrompt.includes("LANGUAGE: Use Russian"));
|
|
assert(teacher.state.systemPrompt.includes("interactive demos"));
|
|
// An explicit tool registry avoids IO: this checks the actual agent composition seam.
|
|
const practice = createPracticeAgent({} as never, { engine, tools: {}, language: "en" });
|
|
assert(practice.state.systemPrompt.includes("LANGUAGE: Use English"));
|
|
});
|
|
it("localizes native desktop controls and uses the same automatic fallback", () => {
|
|
assert.equal(native.resolveLanguage(undefined, ["ru-RU"]), "ru");
|
|
assert.equal(native.resolveLanguage(undefined, ["fr-FR"]), "en");
|
|
assert.equal(native.resolveLanguage("en", ["ru-RU"]), "en");
|
|
assert.equal(native.translate("ru", "Check for Updates…"), "Проверить обновления…");
|
|
assert.equal(native.translate("ru", "LearnHarness {version} is the latest version.", { version: "0.3.7" }), "LearnHarness 0.3.7 — последняя версия.");
|
|
});
|
|
});
|