2026-10-25 17:18:44 +03:00
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" ;
2026-10-26 02:14:27 +03:00
import { resolveSettings } from "../src/core/settings-resolution.ts" ;
2026-10-26 00:58:25 +03:00
import { countLabel , dictationUrl , resolveLanguage , resolveSttLanguage , systemLanguage , translate } from "../src/core/i18n.ts" ;
2026-10-25 17:18:44 +03:00
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" );
});
2026-10-26 00:58:25 +03:00
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" );
});
2026-10-25 17:18:44 +03:00
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 });
});
2026-10-26 00:58:25 +03:00
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»" );
});
2026-10-25 17:18:44 +03:00
it ( "pins an explicit supported language in the STT provider request" , () => {
2026-10-26 00:58:25 +03:00
for ( const language of [ "en" , "ru" , "multi" ] as const ) {
2026-10-25 17:18:44 +03:00
const url = new URL ( dictationUrl ( language ));
assert . equal ( url . protocol , "wss:" );
assert . equal ( url . hostname , "api.deepgram.com" );
2026-10-26 00:58:25 +03:00
assert . deepEqual ( Object . fromEntries ( url . searchParams ), {
model : "nova-3" , language , encoding : "linear16" , sample_rate : "16000" ,
channels : "1" , smart_format : "true" , punctuate : "true" , endpointing : "300" ,
});
2026-10-25 17:18:44 +03:00
}
});
2026-10-26 02:14:27 +03:00
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 ));
}
});
2026-10-25 17:18:44 +03:00
});
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" );
});
2026-10-31 13:45:06 +03:00
it ( "localizes the approved background-job completion wording" , () => {
assert . equal ( translate ( "en" , "finished" ), "finished" );
assert . equal ( translate ( "ru" , "finished" ), "завершено" );
assert . equal ( translate ( "ru" , "failed" ), "ошибка" );
});
2026-10-25 17:18:44 +03:00
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="Язык распознавания речи"' ));
2026-10-25 21:19:48 +03:00
assert ( html . includes ( 'aria-haspopup="menu"' ));
assert ( html . includes ( 'aria-expanded="false"' ));
2026-10-25 17:18:44 +03:00
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 — последняя версия." );
});
});