From 2b5152608c6a8f5781f2c1d856b75b1c57c6d579 Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:10:59 +0300 Subject: [PATCH 1/2] =?UTF-8?q?test(i18n):=20vitest-=D1=87=D0=B5=D0=BA=20?= =?UTF-8?q?=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20en/ru=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20+=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=B2=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 20 ++++++- src/locales/locales.test.ts | 101 ++++++++++++++++++++++++++++++++++++ src/locales/ru.json | 2 + 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/locales/locales.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5630838..92212a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/src/locales/locales.test.ts b/src/locales/locales.test.ts new file mode 100644 index 0000000..f8481c7 --- /dev/null +++ b/src/locales/locales.test.ts @@ -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 { + const out = new Map(); + 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): Set { + 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([]); + }); +}); diff --git a/src/locales/ru.json b/src/locales/ru.json index 79f6820..d9044ea 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2924,6 +2924,7 @@ "namePlaceholder": "Введите название тарифа", "description": "Описание", "descriptionPlaceholder": "Краткое описание тарифа", + "descriptionPlaceholder2": "Краткое описание тарифа", "trafficLimit": "Лимит трафика", "trafficHint": "0 = безлимитный трафик", "deviceLimit": "Лимит устройств", @@ -3814,6 +3815,7 @@ "purchases": "Покупок", "campaign": "Рекламная кампания", "referralsList": "Список рефералов", + "referralEarnings": "Реф. доход", "noReferrals": "Нет рефералов", "panelConfig": "Конфигурация панели", "trojanPassword": "Trojan пароль", From 1fddd9473c30972014139a188b0c23b44cb78397 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 19 Jul 2026 16:23:17 +0300 Subject: [PATCH 2/2] =?UTF-8?q?test(i18n):=20=D0=BE=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=88=D0=B8=D1=82=D1=8C=20allowlist=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D0=BC=D0=B5=D1=80=D0=B6=D0=B0=20#489=20+?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=20realtimeTitle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поверх #491: #489 уже в dev — записи resetPassword.*/expiredDate_trial и placeholder-исключение serverAccess из allowlist удалены (по инструкции самого теста); последняя дыра admin.remnawave.traffic.realtimeTitle закрыта переводом — проверка синхронности теперь строгая, без известных исключений --- src/locales/en.json | 1 + src/locales/locales.test.ts | 18 ++++-------------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 7683adf..2acecf4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1703,6 +1703,7 @@ "totalUpload": "Upload", "totalTraffic": "Total", "online": "online", + "realtimeTitle": "Realtime traffic", "inbounds": "Inbounds", "outbounds": "Outbounds" }, diff --git a/src/locales/locales.test.ts b/src/locales/locales.test.ts index f8481c7..c3d6827 100644 --- a/src/locales/locales.test.ts +++ b/src/locales/locales.test.ts @@ -17,22 +17,12 @@ const RU_ONLY_NAMESPACES = [ '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', +// Известные дыры. #489 смержен, realtimeTitle переведён — список пуст. +const KNOWN_MISSING_IN_EN = new Set([ + // Пусто — так и держать: новые ключи переводите в en.json, а не вносите сюда. ]); -// Удалить после мержа PR #489 (en-значение получит {{count}}). -const KNOWN_PLACEHOLDER_MISMATCHES = new Set(['promo.offers.serverAccess']); +const KNOWN_PLACEHOLDER_MISMATCHES = new Set([]); // Плюральные категории i18next: ru использует _one/_few/_many, en — _one/_other. // Сравниваем БАЗОВЫЕ ключи (без плюрального суффикса); context-варианты