Merge branch 'test/locale-sync' into test-merge-491

This commit is contained in:
Fringg
2026-07-19 16:22:02 +03:00
3 changed files with 122 additions and 1 deletions

View File

@@ -10,8 +10,26 @@ jobs:
lint:
uses: ./.github/workflows/lint.yml
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
build:
needs: lint
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- name: Checkout code

101
src/locales/locales.test.ts Normal file
View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import en from './en.json';
import ru from './ru.json';
/**
* Синхронность en/ru локалей. i18next настроен с fallbackLng: 'ru' — ключ,
* отсутствующий в en.json, отдаёт англоязычным пользователям РУССКИЙ текст
* (а не инлайн-дефолт из кода). Так весь namespace resetPassword.* уехал в
* прод по-русски. Тест ловит новые дыры при добавлении ключей в одну локаль.
*/
// Русскоязычные словари бэкенд-настроек — осознанно только в ru
// (en падает на сырые имена настроек, это админ-экран).
const RU_ONLY_NAMESPACES = [
'admin.settings.settingNames.',
'admin.settings.categories.',
'admin.settings.presets.',
];
// Известные дыры на момент добавления теста. НЕ добавлять сюда новые ключи —
// переводите en.json. Записи ниже удалить после мержа PR #489.
const KNOWN_MISSING_IN_EN = new Set([
'admin.remnawave.traffic.realtimeTitle',
'dashboard.expired.expiredDate_trial',
'resetPassword.enterNewPassword',
'resetPassword.invalidToken',
'resetPassword.redirectingToLogin',
'resetPassword.setPassword',
'resetPassword.success',
'resetPassword.title',
'resetPassword.tokenExpiredOrInvalid',
]);
// Удалить после мержа PR #489 (en-значение получит {{count}}).
const KNOWN_PLACEHOLDER_MISMATCHES = new Set(['promo.offers.serverAccess']);
// Плюральные категории i18next: ru использует _one/_few/_many, en — _one/_other.
// Сравниваем БАЗОВЫЕ ключи (без плюрального суффикса); context-варианты
// (напр. _trial) — самостоятельные ключи и обязаны существовать в обеих локалях.
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
type Tree = { [key: string]: Tree | string };
function flatten(tree: Tree, prefix = ''): Map<string, string> {
const out = new Map<string, string>();
for (const [key, value] of Object.entries(tree)) {
const path = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'string') {
out.set(path, value);
} else {
for (const [k, v] of flatten(value, path)) {
out.set(k, v);
}
}
}
return out;
}
const enFlat = flatten(en as Tree);
const ruFlat = flatten(ru as Tree);
function baseKeys(flat: Map<string, string>): Set<string> {
return new Set([...flat.keys()].map((k) => k.replace(PLURAL_SUFFIX, '')));
}
const enBases = baseKeys(enFlat);
const ruBases = baseKeys(ruFlat);
describe('синхронность локалей en/ru', () => {
it('каждый en-ключ существует в ru', () => {
const missing = [...enBases].filter((k) => !ruBases.has(k));
expect(missing).toEqual([]);
});
it('каждый ru-ключ существует в en (кроме ru-only словарей настроек)', () => {
const missing = [...ruBases].filter(
(k) =>
!enBases.has(k) &&
!RU_ONLY_NAMESPACES.some((ns) => k.startsWith(ns)) &&
!KNOWN_MISSING_IN_EN.has(k),
);
expect(missing).toEqual([]);
});
it('плейсхолдеры {{...}} совпадают в общих ключах', () => {
const PH = /\{\{[^}]+\}\}/g;
const mismatches: string[] = [];
for (const [key, ruValue] of ruFlat) {
const enValue = enFlat.get(key);
if (enValue === undefined || KNOWN_PLACEHOLDER_MISMATCHES.has(key)) {
continue;
}
const ruPh = (ruValue.match(PH) ?? []).sort().join(',');
const enPh = (enValue.match(PH) ?? []).sort().join(',');
if (ruPh !== enPh) {
mismatches.push(`${key}: ru=[${ruPh}] en=[${enPh}]`);
}
}
expect(mismatches).toEqual([]);
});
});

View File

@@ -2924,6 +2924,7 @@
"namePlaceholder": "Введите название тарифа",
"description": "Описание",
"descriptionPlaceholder": "Краткое описание тарифа",
"descriptionPlaceholder2": "Краткое описание тарифа",
"trafficLimit": "Лимит трафика",
"trafficHint": "0 = безлимитный трафик",
"deviceLimit": "Лимит устройств",
@@ -3814,6 +3815,7 @@
"purchases": "Покупок",
"campaign": "Рекламная кампания",
"referralsList": "Список рефералов",
"referralEarnings": "Реф. доход",
"noReferrals": "Нет рефералов",
"panelConfig": "Конфигурация панели",
"trojanPassword": "Trojan пароль",