feat: add Russian desktop localization

This commit is contained in:
2026-07-11 12:50:00 +08:00
parent 8b1151e03a
commit 7f0cc308c2
26 changed files with 1518 additions and 259 deletions
+112
View File
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import {
createI18n,
resolveLocale,
} from '../src/lib/i18n/index.js';
import englishShellCatalog from '../src/lib/i18n/catalogs/en.js';
import russianShellCatalog from '../src/lib/i18n/catalogs/ru.js';
assert.deepEqual(
Object.keys(russianShellCatalog).sort(),
Object.keys(englishShellCatalog).sort(),
'English and Russian shell catalogs must have identical keys',
);
assert.equal(resolveLocale('system', ['ru-RU']), 'ru');
assert.equal(resolveLocale('system', ['uk-UA', 'en-US']), 'en');
assert.equal(resolveLocale('ru', ['en-US']), 'ru');
assert.equal(resolveLocale('en', ['ru-RU']), 'en');
assert.throws(() => resolveLocale('de', ['de-DE']), /unsupported language/);
const catalogs = {
'localized.plugin': {
en: { 'manifest.name': 'Localized Plugin', greeting: 'Hello, {name}!' },
ru: {
'manifest.name': 'Локализованный плагин',
'contributions.views.localized.view.title': 'Локализованный экран',
greeting: 'Привет, {name}!',
},
},
};
const loads = [];
const service = createI18n({
shellCatalogs: {
en: { loading: 'Loading {name}...', fallbackOnly: 'English fallback' },
ru: { loading: 'Загрузка {name}...' },
},
systemLanguages: () => ['ru-RU'],
loadPluginCatalog: async (pluginId, locale) => {
loads.push(`${pluginId}:${locale}`);
return catalogs[pluginId]?.[locale] || {};
},
});
await service.initialize('system');
assert.equal(service.getLanguagePreference(), 'system');
assert.equal(service.getLocale(), 'ru');
assert.equal(service.t('loading', { name: 'Верстак' }), 'Загрузка Верстак...');
assert.equal(service.t('fallbackOnly'), 'English fallback');
assert.equal(service.t('missing', undefined, 'Explicit fallback'), 'Explicit fallback');
assert.equal(service.t('unknown'), 'unknown');
await service.loadPlugin('localized.plugin', {
defaultLocale: 'en',
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
});
assert.deepEqual(loads.sort(), ['localized.plugin:en', 'localized.plugin:ru']);
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'Мир' }), 'Привет, Мир!');
const plugin = {
manifest: {
id: 'localized.plugin',
name: 'Localized Plugin',
description: 'Literal description',
contributes: {
views: [{ id: 'localized.view', title: 'Literal View', component: 'View' }],
},
},
};
const localized = service.localizePlugin(plugin);
assert.notEqual(localized, plugin);
assert.equal(localized.manifest.name, 'Локализованный плагин');
assert.equal(localized.manifest.description, 'Literal description');
assert.equal(localized.manifest.contributes.views[0].title, 'Локализованный экран');
assert.equal(plugin.manifest.name, 'Localized Plugin');
const summary = service.localizeContributionSummary({
views: [{ pluginId: 'localized.plugin', id: 'localized.view', title: 'Literal View', component: 'View' }],
});
assert.equal(summary.views[0].title, 'Локализованный экран');
let notifications = 0;
const unsubscribe = service.subscribe(() => { notifications += 1; });
assert.equal(notifications, 1);
await service.setLanguagePreference('en');
assert.equal(service.getLocale(), 'en');
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'World' }), 'Hello, World!');
assert.equal(notifications, 2);
unsubscribe();
await service.setLanguagePreference('ru');
assert.equal(notifications, 2);
const resilientService = createI18n({
shellCatalogs: { en: { title: 'Title' }, ru: { title: 'Заголовок' } },
systemLanguages: () => ['en-US'],
loadPluginCatalog: async () => { throw new Error('broken catalog'); },
});
await resilientService.initialize('en');
await assert.rejects(
resilientService.loadPlugin('broken.plugin', {
defaultLocale: 'en',
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
}),
/broken catalog/,
);
let resilientNotification = '';
resilientService.subscribe((nextLocale) => { resilientNotification = nextLocale; });
await resilientService.setLanguagePreference('ru');
assert.equal(resilientService.getLanguagePreference(), 'ru');
assert.equal(resilientService.getLocale(), 'ru');
assert.equal(resilientNotification, 'ru');
assert.equal(resilientService.t('title'), 'Заголовок');
console.log('i18n service tests passed');
@@ -43,10 +43,27 @@ globalThis.window = {
},
};
globalThis.__mockApp = window.go.api.App;
const localeListeners = new Set();
globalThis.__mockI18n = {
getLocale: () => 'ru',
translatePlugin: (_pluginId, key, params, fallback) => {
const messages = { greeting: 'Привет, {name}!' };
const message = messages[key] || fallback || key;
return message.replace(/\{([^}]+)\}/g, (placeholder, name) => (
Object.prototype.hasOwnProperty.call(params || {}, name) ? String(params[name]) : placeholder
));
},
subscribe: (listener) => {
localeListeners.add(listener);
listener('ru');
return () => localeListeners.delete(listener);
},
};
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
const source = fs.readFileSync(sourcePath, 'utf8')
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;');
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;')
.replace("import { i18n } from '../i18n/index.js';", 'const i18n = globalThis.__mockI18n;');
const tempPath = path.resolve('/tmp/verstak-plugin-api-contributions-test.mjs');
fs.writeFileSync(tempPath, source);
@@ -59,6 +76,17 @@ if (!api.contributions || typeof api.contributions.list !== 'function') {
if (!api.commands || typeof api.commands.executeFor !== 'function') {
throw new Error('api.commands.executeFor is missing');
}
if (!api.i18n || typeof api.i18n.getLocale !== 'function' || typeof api.i18n.t !== 'function' || typeof api.i18n.onDidChangeLocale !== 'function') {
throw new Error('api.i18n contract is missing');
}
if (api.i18n.getLocale() !== 'ru' || api.i18n.t('greeting', { name: 'Мир' }) !== 'Привет, Мир!') {
throw new Error('api.i18n locale or translation is incorrect');
}
let localeNotifications = 0;
api.i18n.onDidChangeLocale(() => { localeNotifications += 1; });
if (localeNotifications !== 1 || localeListeners.size !== 1) {
throw new Error('api.i18n locale subscription was not registered');
}
const fileActions = await api.contributions.list('fileActions');
if (fileActions.length !== 1 || fileActions[0].id !== 'provider.file.action') {
@@ -88,4 +116,9 @@ if (stored.version !== 1 || stored.workspaceRootPath !== 'Project') {
throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`);
}
api.dispose();
if (localeListeners.size !== 0) {
throw new Error('api.i18n locale subscription was not disposed');
}
console.log('plugin api contributions smoke passed');