From c1d2957ba5453d8a88139bba9b1a62a5793dd27f Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Fri, 25 Sep 2026 15:52:30 +0500 Subject: [PATCH] chore: update lint rules --- docs/README.md | 8 +- docs/backlog.md | 6 +- docs/plan-codebase-quality.md | 29 ++--- docs/plan-preview-i18n.md | 16 +-- docs/quality-gates.md | 15 ++- web/eslint-plugins/README.md | 13 ++- .../__tests__/dict-consistency.test.ts | 64 ++++++++++ web/eslint-plugins/i18n/dict-consistency.js | 110 ++++++++++++++---- web/eslint.config.js | 14 ++- web/scripts/lint-all.mjs | 2 +- web/src/lib/i18n/ru.ts | 56 +-------- web/src/lib/i18n/schema-tool-strings.test.ts | 19 ++- web/src/lib/i18n/tools-i18n.test.ts | 32 +++++ 13 files changed, 268 insertions(+), 116 deletions(-) diff --git a/docs/README.md b/docs/README.md index ec4ad86..eeffd96 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,10 +57,10 @@ - registry нового UI содержит 122 инструмента; - `pnpm --dir web check` проходит без ошибок и предупреждений; -- `pnpm --dir web lint:all` завершается с 0 errors, но с 188 warnings - `i18n/dict-consistency` и 4 unused design tokens; -- unit/e2e результаты считать актуальными только после отдельного прогона; - исторические числа в `docs/archive/` не являются baseline. +- `pnpm --dir web lint:all` завершается с 0 errors и 0 i18n warnings; остаются 4 + unused design tokens от token audit; +- `pnpm verify` проходит; unit/e2e результаты считать актуальными только после + отдельного прогона, исторические числа в `docs/archive/` не являются baseline. Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`. diff --git a/docs/backlog.md b/docs/backlog.md index 5759e5c..a07e5d0 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -5,8 +5,8 @@ - [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые значения, совпадение `{placeholder}`-переменных между всеми локалями) — правило `i18n/dict-consistency`, детали в §Фаза 8 - `docs/plan-preview-i18n.md`. Правило реализовано; доведение baseline до - zero-warning вынесено в Q2 `docs/plan-codebase-quality.md`. + `docs/plan-preview-i18n.md`. Правило реализовано; Q2 перевёл i18n baseline + к zero-warning через policy-aware fallback, не меняя `/v1`. - [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть что за `SonarLint` или написать кастомный линтер) - [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n @@ -14,6 +14,8 @@ - [ ] консистентность комментариев - если есть описание к одному инструменту - оно должно быть у всех или объяснено, почему этот инструмент такой особенный и заслуживает комментарий +- [ ] удалить legacy `tools.*.params` из root i18n после проверки потребителей; + до этого Q2 держит pattern-based policy-исключение ## Мелочи всякие diff --git a/docs/plan-codebase-quality.md b/docs/plan-codebase-quality.md index bad6755..b66bb42 100644 --- a/docs/plan-codebase-quality.md +++ b/docs/plan-codebase-quality.md @@ -112,21 +112,22 @@ pnpm --dir web test:e2e ### Q2. Политика i18n и zero-warning baseline -**Оценка:** M. **Зависимости:** Q0, Q1. +**Статус:** готов к ревью. **Проверено:** i18n lint даёт 0 warnings, rule-тесты +и полный `pnpm verify` проходят. **Оценка:** M. **Зависимости:** Q0, Q1. -- Решить, являются ли `registry.title/description` физическими ключами словарей - или допустимым английским fallback. -- Не исправлять расхождения массовым allowlist-ом. Сначала классифицировать их - на реальные пропуски и осознанные fallback-источники. -- Дополнить словари до согласованной схемы, включая реальные пропуски в `ru.ts`. -- После очистки включить zero-warning gate. -- Отдельно проверить, что изменения в i18n не требуют синхронного изменения - `v1`-словарей: новая и legacy-ветки изолированы. -- Не начинать plural-формы до решения модели `string | PluralForms` - (`docs/plan-preview-i18n.md:289-307`). - -**Готово, когда:** dictionary linter даёт ноль несогласованностей без -молчаливого подавления ошибок, а тесты словарей проходят в обеих локалях. +- `BASE_LOCALE` — единственный источник registry-English fallback для + `tools.*.title` и `tools.*.description`; остальные локали требуют физические + ключи. +- Legacy `tools.*.params` исключён pattern-based policy до отдельной очистки + мёртвого словаря; пустые значения и placeholders не подавляются. +- Исправлены три реальных RU option-label gaps и удалены неиспользуемые legacy + option-группы из `ru.ts`. +- Исправлен tie-break placeholder parity: при равенстве голосов выбирается + `BASE_LOCALE`. +- `lint-all` запускает ESLint с `--max-warnings=0`; token audit пока оставляет 4 + unused-token warnings отдельным debt. +- Plural-формы остаются отдельной задачей после Q2 и не смешиваются с этим + этапом. ### Q3. Тестовый контракт e2e diff --git a/docs/plan-preview-i18n.md b/docs/plan-preview-i18n.md index d0c0a98..fb83913 100644 --- a/docs/plan-preview-i18n.md +++ b/docs/plan-preview-i18n.md @@ -1,9 +1,9 @@ # План: i18n в preview (новый UI) > Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI, -> локализация ошибок и `i18n/dict-consistency` уже реализованы; zero-warning -> baseline и plural-формы остаются Q2. Актуальные решения и ограничения — в -> `docs/plan-codebase-quality.md`. +> локализация ошибок, `i18n/dict-consistency` и zero-warning baseline Q2 +> реализованы; plural-формы остаются отдельной задачей. Актуальные решения и +> ограничения — в `docs/plan-codebase-quality.md`. ## 1. Что получается @@ -279,11 +279,11 @@ `empty value "x.y" in en`. - **Паритет плейсхолдеров**: у одинакового ключа набор `{name}` одинаковый во всех локалях — поймать потерянную при переводе переменную. -- **8.3** Цель — zero-warn. Сейчас `en.ts` неполный (в `tools` только - результаты-помощники, остальной английский — из реестра), поэтому после - включения будет много warn'ов: доводим `en.ts` до полноты `ru.ts` (секции + - `tools` полностью, source — registry-English, без дрейфа). До достижения цели - допускается allowlist-механизм в ядре правила для осознанных отклонений. +- **8.3** Политика источников: `tools.*.title` и `tools.*.description` в + `BASE_LOCALE` намеренно берутся из registry English fallback; остальные локали + обязаны иметь физические переводы. Старый `tools.*.params` не используется + новым UI и пока исключён policy-pattern, а не списком id. Target — zero-warn + без молчаливых подавлений. - **8.4** Тесты правила — фикстуры в `web/eslint-plugins/__fixtures__/`, док в `web/eslint-plugins/README.md`; включение в `eslint.config.js` как `"warn"`. diff --git a/docs/quality-gates.md b/docs/quality-gates.md index 92baa57..99919b1 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -50,12 +50,15 @@ baseline; после закрытия известных ошибок его м ## Errors и warnings - `errors` завершают соответствующий инструмент ненулевым кодом. -- `warnings` не завершают текущий lint автоматически, пока не включён строгий - режим. -- На baseline Q0 `lint:all` даёт 0 errors, 188 `i18n/dict-consistency` warnings - и 4 unused-token warnings. Это известные долги, а не чистый результат. -- После Q2 parity-линтер должен перейти к zero-warning, после чего можно - включить `--max-warnings=0` без обходных allowlist-ов. +- После Q2 ESLint запускается с `--max-warnings=0`: новые ESLint warnings + блокируют gate. +- Token audit пока оставляет unused-token warnings отдельным неблокирующим + сообщением. +- `lint:all` по-прежнему сообщает 4 unused-token warnings отдельным + предупреждением token audit; это следующий debt Q6. +- `dict-consistency` использует base-locale fallback для registry metadata и + pattern-based policy для legacy `tools.*.params`; это не молчаливый + `allowPaths`-список. ## CI diff --git a/web/eslint-plugins/README.md b/web/eslint-plugins/README.md index d4a0b40..999ced7 100644 --- a/web/eslint-plugins/README.md +++ b/web/eslint-plugins/README.md @@ -80,14 +80,21 @@ пропавшая целая секция репортится один раз (не по каждому потомку); - **пустые значения**: `""` и строка из одних пробелов; - **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с - каноническим (мажоритарный по локалям, при равенстве — первый по `LOCALES`) - — ловит потерянную при переводе переменную ровно один раз, даже когда - отклоняется одна-единственная локаль. + каноническим (мажоритарный по локалям, при равенстве — `BASE_LOCALE`, затем + `LOCALES`) — ловит потерянную при переводе переменную ровно один раз, даже + когда отклоняется одна-единственная локаль. - Словари-соседи читаются с диска и кэшируются на процесс линта (как `no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser` (синтакс, без типов). - Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок (осознанные отклонения до достижения zero-warn). +- `baseLocaleFallback: string[]` — паттерны ключей, которые допустимо + отсутствовать только в базовой локали; в текущей конфигурации это registry + metadata `tools.*.title` и `tools.*.description`. Пустые значения и + placeholders для присутствующих ключей всё равно проверяются. +- `ignoreMissingPatterns: string[]` — паттерны legacy-ключей, которые временно + не проверяются на missing; в текущей конфигурации это старый `tools.*.params`, + не используемый новым UI. Эта опция не отключает empty/placeholder checks. - Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в `__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на diff --git a/web/eslint-plugins/__tests__/dict-consistency.test.ts b/web/eslint-plugins/__tests__/dict-consistency.test.ts index 4924bbe..655d580 100644 --- a/web/eslint-plugins/__tests__/dict-consistency.test.ts +++ b/web/eslint-plugins/__tests__/dict-consistency.test.ts @@ -58,6 +58,70 @@ describe("i18n/dict-consistency", () => { expect(messageIds(lint(fixture("de.ts"), "lib/i18n/de.ts"))).toEqual([]); }); + const registryPolicy = { + baseLocaleFallback: ["tools.*.title", "tools.*.description"], + ignoreMissingPatterns: ["tools.*.params"], + }; + + it("allows registry metadata only in the base locale", () => { + const enMissing = fixture("en.ts") + .replace('\t\t\ttitle: "Add border",\n', "") + .replace('\t\t\tdescription: "Draws a {kind} frame.",\n', ""); + expect( + messageIds(lint(enMissing, "lib/i18n/en.ts", registryPolicy)), + ).toEqual([]); + + const ruMissing = fixture("ru.ts").replace( + '\t\t\ttitle: "Добавить рамку",\n', + "", + ); + expect( + messageIds(lint(ruMissing, "lib/i18n/ru.ts", registryPolicy)), + ).toEqual(["missingKey"]); + }); + + it("does not warn for a missing tool object with only fallback metadata", () => { + const enMissingTool = fixture("en.ts").replace( + /\t\taddBorder: \{[\s\S]*?\n\t\t\},\n/, + "", + ); + expect( + messageIds( + lint(enMissingTool, "lib/i18n/en.ts", { + ...registryPolicy, + ignoreMissingPatterns: [ + ...registryPolicy.ignoreMissingPatterns, + "tools.*.results", + ], + }), + ), + ).toEqual([]); + }); + + it("does not warn for configured legacy missing paths", () => { + const enMissingResults = fixture("en.ts").replace( + /\t\t\tresults: \{[\s\S]*?\n\t\t\t\},\n/, + "", + ); + expect( + messageIds( + lint(enMissingResults, "lib/i18n/en.ts", { + ignoreMissingPatterns: ["tools.*.results"], + }), + ), + ).toEqual([]); + }); + + it("still reports empty fallback metadata", () => { + const enEmpty = fixture("en.ts").replace( + 'title: "Add border",', + 'title: "",', + ); + expect(messageIds(lint(enEmpty, "lib/i18n/en.ts", registryPolicy))).toEqual( + ["emptyValue"], + ); + }); + it("flags a leaf key missing in the current file", () => { const enMissing = fixture("en.ts").replace( '\t\trestoreLast: "Restore {title}",', diff --git a/web/eslint-plugins/i18n/dict-consistency.js b/web/eslint-plugins/i18n/dict-consistency.js index 07d4888..7d7f591 100644 --- a/web/eslint-plugins/i18n/dict-consistency.js +++ b/web/eslint-plugins/i18n/dict-consistency.js @@ -13,16 +13,17 @@ * section is reported once, not per descendant key); * - empty values: `""` or whitespace-only values; * - placeholders: for a shared key the `{name}` set is compared against the - * canonical set (the one shared by most locales, ties broken by LOCALES - * order) — catches a variable lost in translation exactly once, even when - * only one locale deviates. + * canonical set (the one shared by most locales, ties broken by BASE_LOCALE + * then LOCALES order) — catches a variable lost in translation exactly once, + * even when only one locale deviates. * * The rule self-filters: files whose basename is not one of LOCALES are * ignored, so it can be attached to the whole `lib/i18n/` directory. * * Options (object, all optional): - * - allowPaths: dot-path keys excluded from every check (conscious - * deviations until the dict reaches zero-warn, see Фаза 8 §8.3). + * - allowPaths: dot-path keys excluded from every check. + * - baseLocaleFallback: patterns whose missing keys are allowed in BASE_LOCALE. + * - ignoreMissingPatterns: legacy patterns excluded from missing-key checks. */ import fs from "node:fs"; import path from "node:path"; @@ -70,6 +71,22 @@ function findDictExport(ast) { return null; } +function findBaseLocale(ast) { + for (const node of ast.body) { + if (node.type !== "ExportNamedDeclaration") continue; + const decl = node.declaration; + if (!decl || decl.type !== "VariableDeclaration") continue; + for (const d of decl.declarations) { + if (d.id.type !== "Identifier" || d.id.name !== "BASE_LOCALE") { + continue; + } + const init = unwrap(d.init); + if (isStringLiteral(init)) return init.value; + } + } + return null; +} + /** The `LOCALES` list exported from `dict.ts`, or null. */ function findLocalesList(ast) { for (const node of ast.body) { @@ -167,6 +184,17 @@ function formatSet(set) { return `{${[...set].sort().join(", ")}}`; } +function matchesPattern(key, pattern) { + const keyParts = key.split("."); + const patternParts = pattern.split("."); + return ( + keyParts.length >= patternParts.length && + patternParts.every( + (part, index) => part === "*" || part === keyParts[index], + ) + ); +} + /** Ancestor paths of a dot-path (e.g. `a.b` and `a` for `a.b.c`). */ function* ancestorPaths(key) { let i = key.lastIndexOf("."); @@ -190,6 +218,7 @@ function readDir(dir) { if (!fs.existsSync(dictPath)) return null; const ast = parseTs(fs.readFileSync(dictPath, "utf8"), dictPath); const locales = findLocalesList(ast); + const baseLocale = findBaseLocale(ast); if (!locales || locales.length === 0) return null; const dicts = []; @@ -218,31 +247,42 @@ function readDir(dir) { unionPaths.add(key); if (entry.kind === STRING && (entry.value ?? "").trim() !== "") { if (!stringLeaves.has(key)) stringLeaves.set(key, []); - stringLeaves.get(key).push(placeholders(entry.value)); + stringLeaves.get(key).push({ + locale: d.locale, + set: placeholders(entry.value), + }); } } } // Canonical placeholder set per key: the set shared by the most locales; - // on a tie the first one encountered (LOCALES order) wins. + // on a tie the base locale wins before LOCALES order. const canonicalSets = new Map(); - for (const [key, sets] of stringLeaves) { + for (const [key, entries] of stringLeaves) { const counts = new Map(); - for (const set of sets) { + for (const { set } of entries) { const sig = [...set].sort().join("\u0000"); counts.set(sig, (counts.get(sig) || 0) + 1); } let bestCount = 0; - for (const set of sets) { + let bestLocale = null; + for (const { locale, set } of entries) { const sig = [...set].sort().join("\u0000"); - if (counts.get(sig) > bestCount) { - bestCount = counts.get(sig); + const count = counts.get(sig); + if ( + count > bestCount || + (count === bestCount && + locale === baseLocale && + bestLocale !== baseLocale) + ) { + bestCount = count; + bestLocale = locale; canonicalSets.set(key, set); } } } - return { locales, dicts, unionPaths, canonicalSets }; + return { locales, baseLocale, dicts, unionPaths, canonicalSets }; } export default { @@ -261,6 +301,14 @@ export default { type: "array", items: { type: "string" }, }, + baseLocaleFallback: { + type: "array", + items: { type: "string" }, + }, + ignoreMissingPatterns: { + type: "array", + items: { type: "string" }, + }, }, additionalProperties: false, }, @@ -275,6 +323,8 @@ export default { create(context) { const options = context.options[0] || {}; const allowPaths = new Set(options.allowPaths || []); + const baseLocaleFallback = options.baseLocaleFallback || []; + const ignoreMissingPatterns = options.ignoreMissingPatterns || []; const filename = path.resolve( context.filename || context.physicalFilename || "", @@ -287,16 +337,36 @@ export default { if (!dictExport) return {}; const own = buildKeyMap(dictExport.object); + const isMissingAllowed = (key) => { + if (allowPaths.has(key)) return true; + if ( + ignoreMissingPatterns.some((pattern) => matchesPattern(key, pattern)) + ) { + return true; + } + return ( + locale === dirInfo.baseLocale && + baseLocaleFallback.some((pattern) => matchesPattern(key, pattern)) + ); + }; - // Missing keys: everything the union has but the current file lacks. - // Top-level gaps only — a missing ancestor already covers its subtree. - const missing = [...dirInfo.unionPaths].filter((k) => !own.has(k)); - const missingSet = new Set(missing); - const topMissing = missing.filter( - (k) => ![...ancestorPaths(k)].some((a) => missingSet.has(a)), + const missing = [...dirInfo.unionPaths].filter((key) => !own.has(key)); + const requiredMissing = missing.filter((key) => { + if (isMissingAllowed(key)) return false; + const descendants = missing.filter((candidate) => + candidate.startsWith(`${key}.`), + ); + return ( + descendants.length === 0 || + descendants.some((candidate) => !isMissingAllowed(candidate)) + ); + }); + const requiredSet = new Set(requiredMissing); + const topMissing = requiredMissing.filter( + (key) => + ![...ancestorPaths(key)].some((ancestor) => requiredSet.has(ancestor)), ); for (const key of topMissing) { - if (allowPaths.has(key)) continue; context.report({ node: dictExport.id, messageId: "missingKey", diff --git a/web/eslint.config.js b/web/eslint.config.js index ad91c67..e699e1c 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -175,16 +175,22 @@ export default tseslint.config( }, }, // Кросс-языковой линтер словарей (плагин i18n/dict-consistency). - // warn-only: пропущенный перевод не должен валить сборку. Список локалей - // берётся из LOCALES в lib/i18n/dict.ts — новые локали подхватываются - // автоматически; правило игнорирует не-локали в этой папке само. + // Правило сообщает warning, а `lint:all` превращает новые warnings в + // blocking через --max-warnings=0. Список локалей берётся из LOCALES в + // lib/i18n/dict.ts; новые локали подхватываются автоматически. { files: ["**/src/lib/i18n/*.ts"], plugins: { i18n: i18nPlugin, }, rules: { - "i18n/dict-consistency": "warn", + "i18n/dict-consistency": [ + "warn", + { + baseLocaleFallback: ["tools.*.title", "tools.*.description"], + ignoreMissingPatterns: ["tools.*.params"], + }, + ], }, }, // Полные recommended-наборы — только на новый код. diff --git a/web/scripts/lint-all.mjs b/web/scripts/lint-all.mjs index f22df68..7397e52 100644 --- a/web/scripts/lint-all.mjs +++ b/web/scripts/lint-all.mjs @@ -31,7 +31,7 @@ const bins = { const steps = [ { name: "ESLint (svelte) + design-tokens rules", - args: [bins.eslint, ...colorFlag, "."], + args: [bins.eslint, ...colorFlag, "--max-warnings=0", "."], }, { name: "Stylelint (css) — design-token style", diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index b42b1c1..4ff18af 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -724,9 +724,6 @@ export const ru: Dict = { toColor: "Цвет конца", direction: "Направление", }, - options: { - direction: { horizontal: "По горизонтали", vertical: "По вертикали" }, - }, }, "color-wheel-png": { title: "Цветовой круг PNG", @@ -1051,13 +1048,6 @@ export const ru: Dict = { backgroundColor: "Цвет фона", padding: "Отступ, px", }, - options: { - font: { - sans: "Без засечек", - serif: "С засечками", - mono: "Моноширинный", - }, - }, }, "emoji-to-png": { title: "Эмодзи в PNG", @@ -1135,8 +1125,11 @@ export const ru: Dict = { "8": "8", "16": "16", "32": "32", + "44": "44 (сильный)", "64": "64", + "96": "96 (сбалансированный)", "128": "128", + "192": "192 (лёгкий)", "256": "256", }, }, @@ -1269,13 +1262,6 @@ export const ru: Dict = { font: "Шрифт", bold: "Жирный", }, - options: { - font: { - sans: "Без засечек", - serif: "С засечками", - mono: "Моноширинный", - }, - }, }, "add-text-png": { title: "Надпись на PNG", @@ -1293,24 +1279,6 @@ export const ru: Dict = { plateColor: "Цвет подложки", plateOpacity: "Прозрачность плашки, %", }, - options: { - font: { - sans: "Без засечек", - serif: "С засечками", - mono: "Моноширинный", - }, - position: { - "top-left": "Сверху слева", - "top-center": "Сверху по центру", - "top-right": "Сверху справа", - "middle-left": "По центру слева", - center: "По центру", - "middle-right": "По центру справа", - "bottom-left": "Снизу слева", - "bottom-center": "Снизу по центру", - "bottom-right": "Снизу справа", - }, - }, }, "date-stamp-png": { title: "Дата-штамп PNG", @@ -1328,24 +1296,6 @@ export const ru: Dict = { plateColor: "Цвет подложки", plateOpacity: "Прозрачность плашки, %", }, - options: { - font: { - sans: "Без засечек", - serif: "С засечками", - mono: "Моноширинный", - }, - position: { - "top-left": "Сверху слева", - "top-center": "Сверху по центру", - "top-right": "Сверху справа", - "middle-left": "По центру слева", - center: "По центру", - "middle-right": "По центру справа", - "bottom-left": "Снизу слева", - "bottom-center": "Снизу по центру", - "bottom-right": "Снизу справа", - }, - }, }, "skew-png": { title: "Наклонить PNG", diff --git a/web/src/lib/i18n/schema-tool-strings.test.ts b/web/src/lib/i18n/schema-tool-strings.test.ts index 3e463fc..7617b3d 100644 --- a/web/src/lib/i18n/schema-tool-strings.test.ts +++ b/web/src/lib/i18n/schema-tool-strings.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, it } from "vitest"; import { TOOLS } from "../registry"; import { setLocale } from "./locale.svelte"; -import { searchTools, verdictText } from "./schema-tool-strings"; +import { + searchTools, + toolDescription, + toolTitle, + verdictText, +} from "./schema-tool-strings"; afterEach(() => { setLocale("ru"); @@ -45,6 +50,18 @@ describe("searchTools (кросс-языковой поиск каталога)" }); }); +describe("metadata fallback", () => { + it("uses registry title and description in the base locale", () => { + setLocale("en"); + for (const tool of TOOLS) { + expect(toolTitle(tool), `${tool.id}: title`).toBe(tool.title); + expect(toolDescription(tool), `${tool.id}: description`).toBe( + tool.description, + ); + } + }); +}); + describe("verdictText (вердикт с vars)", () => { it("интерполирует {vars} в обоих локалях", () => { setLocale("ru"); diff --git a/web/src/lib/i18n/tools-i18n.test.ts b/web/src/lib/i18n/tools-i18n.test.ts index 37c0f8c..e37c7fc 100644 --- a/web/src/lib/i18n/tools-i18n.test.ts +++ b/web/src/lib/i18n/tools-i18n.test.ts @@ -15,6 +15,22 @@ function toolKeys(tool: ToolEntry): { fields: string[]; groups: string[] } { return { fields, groups }; } +type SelectOption = { value: string; label: string }; + +function selectOptions( + tool: ToolEntry, +): { fieldId: string; option: SelectOption }[] { + const result: { fieldId: string; option: SelectOption }[] = []; + for (const [fieldId, field] of Object.entries(tool.schema?.fields ?? {})) { + const spec = ( + field as { spec?: { kind?: string; options?: SelectOption[] } } + ).spec; + if (spec?.kind !== "select") continue; + for (const option of spec.options ?? []) result.push({ fieldId, option }); + } + return result; +} + describe("полнота словарей для нового registry", () => { it("у каждого инструмента есть перевод ru с непустыми title/description", () => { for (const tool of TOOLS) { @@ -43,6 +59,22 @@ describe("полнота словарей для нового registry", () => { } }); + it("все active select options переведены в en и ru", () => { + for (const tool of TOOLS) { + for (const { fieldId, option } of selectOptions(tool)) { + if (fieldId === "component") continue; + const key = `${tool.id}: ${fieldId}.${option.value}`; + expect(en.tools[tool.id]?.options?.[fieldId]?.[option.value], key).toBe( + option.label, + ); + expect( + ru.tools[tool.id]?.options?.[fieldId]?.[option.value], + key, + ).toBeTruthy(); + } + } + }); + it("в словарях нет лишних инструментов", () => { const ids = new Set(TOOLS.map((tool) => tool.id)); for (const dict of [ru.tools, en.tools]) {