diff --git a/docs/plan-preview-i18n.md b/docs/plan-preview-i18n.md index 807025e..1e58796 100644 --- a/docs/plan-preview-i18n.md +++ b/docs/plan-preview-i18n.md @@ -245,7 +245,7 @@ ### Фаза 6: Тесты -- **6.1** `new-tools-i18n.test.ts` — полнота словарей для нового registry: +- **6.1** `tools-i18n.test.ts` — полнота словарей для текущего registry: title/description ru для всех инструментов, каждый `label`-ключ схемы есть в `fields`, каждый `groups`-ключ есть в `groups`, без лишних ключей. - **6.2** Фикс `known-issues.spec.ts` test.fixme — ошибки локализуются. diff --git a/web/e2e/i18n.spec.ts b/web/e2e/i18n.spec.ts new file mode 100644 index 0000000..0044e3f --- /dev/null +++ b/web/e2e/i18n.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "playwright/test"; +import { opaquePng } from "./helpers/fixtures"; +import { openTool, uploadImage } from "./helpers/page"; + +test("RU/EN: переключение переводит заголовок инструмента", async ({ + page, +}) => { + await openTool(page, "resize-png"); + const ruBtn = page.getByRole("button", { name: "RU", exact: true }); + const enBtn = page.getByRole("button", { name: "EN", exact: true }); + await expect(async () => { + await ruBtn.click(); + await expect(page.locator(".schema-tool h1")).toHaveText( + "Изменить размер PNG", + ); + }).toPass(); + await expect(ruBtn).toHaveAttribute("aria-pressed", "true"); + await enBtn.click(); + await expect(page.locator(".schema-tool h1")).toHaveText("Resize PNG"); +}); + +// Валится на известном баге resize-png (дефолтные 0-параметры дают ошибку при +// аплоаде, см. known-issues.spec.ts). Здесь он используется как детерминированная +// ошибка: важно, что её текст локализован, а не сырой ключ. +// TODO: найти другую ошибку, эта будет исправлена при фиксе resize-png +test("ошибка run'а локализуется, не сырой i18n-ключ", async ({ page }) => { + await openTool(page, "resize-png"); + await uploadImage(page, opaquePng); + const alert = page.locator("[role='alert']"); + await expect(alert).toBeVisible(); + const text = (await alert.textContent()) ?? ""; + expect(text).not.toMatch(/^errors\./); + expect(text.length).toBeGreaterThan(3); +}); diff --git a/web/e2e/known-issues.spec.ts b/web/e2e/known-issues.spec.ts index 591f6a6..21e0350 100644 --- a/web/e2e/known-issues.spec.ts +++ b/web/e2e/known-issues.spec.ts @@ -2,10 +2,11 @@ import { expect, test } from "playwright/test"; import { opaquePng } from "./helpers/fixtures"; import { openTool, uploadImage } from "./helpers/page"; -// FIXME: Весь файл — зарегистрированные баги preview (см. docs/checklist-manual-testing.md +// FIXME: В файле зарегистрированы баги preview (см. docs/checklist-manual-testing.md // → «Известные баги preview»). Тела assert'ят ОЖИДАЕМОЕ поведение. Статус fixme // означает «мы знаем, что сейчас падает»; когда баг починят — убрать fixme и -// тест станет зелёным «сам по себе». +// тест станет зелёным «сам по себе». (Тест про локализацию ошибок переехал в +// i18n.spec.ts — он больше не issue.) test.describe("known bugs — documented as fixme", () => { test.fixme("resize-png: upload produces a resized result (no alert)", async ({ @@ -25,16 +26,4 @@ test.describe("known bugs — documented as fixme", () => { await expect(page.locator("[role='alert']")).toHaveCount(0); await expect(page.locator('img[alt="result"]')).toBeVisible(); }); - - test.fixme("error message is localized, not a raw i18n key", async ({ - page, - }) => { - await openTool(page, "resize-png"); - await uploadImage(page, opaquePng); - const alert = page.locator("[role='alert']"); - await expect(alert).toBeVisible(); - const text = (await alert.textContent()) ?? ""; - expect(text).not.toMatch(/^errors\./); - expect(text.length).toBeGreaterThan(3); - }); }); diff --git a/web/eslint.config.js b/web/eslint.config.js index 450c3c9..10cdf6f 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -72,6 +72,7 @@ export default tseslint.config( "e2e/tools-smoke.spec.ts", "e2e/generators.spec.ts", "e2e/known-issues.spec.ts", + "e2e/i18n.spec.ts", // Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в // tsconfig), поэтому для типизированного парсинга резолвятся // через default-проект. Перечисляются точечно: `**` в diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index cc24359..b42b1c1 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -525,64 +525,52 @@ export const ru: Dict = { }, }, "png-to-hsl": { + title: "Разбить PNG на HSL", + description: + "Раскладывает изображение на компоненты тона, насыщенности и светлоты.", options: { display: { gray: "Оттенки серого", color: "Пространство как RGB" }, }, }, "png-to-hsv": { + title: "Разбить PNG на HSV", + description: + "Раскладывает изображение на компоненты тона, насыщенности и значения (яркости).", options: { display: { gray: "Оттенки серого", color: "Пространство как RGB" }, }, }, "png-to-hsi": { + title: "Разбить PNG на HSI", + description: + "Раскладывает изображение на компоненты тона, насыщенности и интенсивности.", options: { display: { gray: "Оттенки серого", color: "Пространство как RGB" }, }, }, "png-to-cmyk": { + title: "Конвертировать PNG в цвета CMYK", + description: + "Раскладывает изображение на печатные компоненты Cyan, Magenta, Yellow и Key (чёрный).", options: { display: { gray: "Оттенки серого", color: "Пространство как RGB" }, }, }, "png-to-ycbcr": { + title: "Конвертировать PNG в цвета YCbCr", + description: + "Раскладывает изображение на яркость (Y) и цветоразностные компоненты Cb / Cr.", options: { display: { gray: "Оттенки серого", color: "Пространство как RGB" }, }, }, "png-to-lab": { - options: { - display: { gray: "Оттенки серого", color: "Пространство как RGB" }, - }, - }, - cmyk: { - title: "Конвертировать PNG в цвета CMYK", - description: - "Раскладывает изображение на печатные компоненты Cyan, Magenta, Yellow и Key (чёрный).", - }, - hsl: { - title: "Разбить PNG на HSL", - description: - "Раскладывает изображение на компоненты тона, насыщенности и светлоты.", - }, - hsi: { - title: "Разбить PNG на HSI", - description: - "Раскладывает изображение на компоненты тона, насыщенности и интенсивности.", - }, - hsv: { - title: "Разбить PNG на HSV", - description: - "Раскладывает изображение на компоненты тона, насыщенности и значения (яркости).", - }, - lab: { title: "Конвертировать PNG в цвета LAB", description: "Раскладывает изображение на перцепционную светлоту и оппонентные пары зелёный–пурпур / синий–жёлтый.", - }, - ycbcr: { - title: "Конвертировать PNG в цвета YCbCr", - description: - "Раскладывает изображение на яркость (Y) и цветоразностные компоненты Cb / Cr.", + options: { + display: { gray: "Оттенки серого", color: "Пространство как RGB" }, + }, }, "black-and-white-png": { title: "Чёрно-белый PNG по порогу", diff --git a/web/src/lib/i18n/smoke-i18n.test.ts b/web/src/lib/i18n/smoke-i18n.test.ts new file mode 100644 index 0000000..acab926 --- /dev/null +++ b/web/src/lib/i18n/smoke-i18n.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getLocale, setLocale } from "./locale.svelte"; +import { LOCALE_TAGS } from "./dict"; +import { normalizeForSearch } from "./matching"; +import { t } from "./t"; +import { getTool, TOOLS } from "../registry"; +import { toolTitle } from "./schema-tool-strings"; + +afterEach(() => { + setLocale("ru"); + vi.unstubAllGlobals(); +}); + +describe("Смоук §6 i18n (новый UI)", () => { + it("1. html lang следует за локалью", () => { + const doc = { documentElement: { lang: "" } }; + vi.stubGlobal("document", doc); + setLocale("en"); + expect(doc.documentElement.lang).toBe("en"); + setLocale("ru"); + expect(doc.documentElement.lang).toBe("ru"); + }); + + it("2. getLocale отражает последний выбор", () => { + setLocale("en"); + expect(getLocale()).toBe("en"); + setLocale("ru"); + expect(getLocale()).toBe("ru"); + }); + + it("3. Ключевые секции переведены без смеси языков", () => { + const samples: Array<[string, string, string]> = [ + ["header.workspace", "Рабочая область", "Workspace"], + ["catalog.heading", "Каталог инструментов", "Tool catalog"], + [ + "home.heroTitle", + "Что делаем с изображением?", + "What do you want to do", + ], + ["chain.inputLegend", "Вход", "Input"], + ["resultCard.nextTool", "Следующий инструмент", "Next tool"], + ["paramsCard.toolSettings", "Настройки инструмента", "Tool settings"], + ["download.busy", "Готовим файл", "Preparing file"], + ["dropZone.pickDefault", "Перетащите изображение", "Drop an image"], + ]; + for (const [key, ruPart, enPart] of samples) { + setLocale("ru"); + expect(t(key), key + " @ru").toContain(ruPart); + setLocale("en"); + expect(t(key), key + " @en").toContain(enPart); + } + }); + + it("4. Заголовок инструмента переключается локалями (schema-tool-strings)", () => { + const tool = getTool("crop-png") ?? TOOLS[0]; + setLocale("ru"); + expect(toolTitle(tool)).toContain("Обрезать"); + setLocale("en"); + expect(toolTitle(tool)).toBe(tool.title); + }); + + it("5. Ошибки с vars локализуются на оба языка", () => { + setLocale("ru"); + expect(t("errors.badHex", { value: "#zz" })).toBe( + 'Некорректный HEX-цвет: "#zz"', + ); + expect(t("errors.toolNotFound", { id: "x" })).toContain("не найден"); + setLocale("en"); + expect(t("errors.badHex", { value: "#zz" })).toBe( + 'Invalid HEX color: "#zz"', + ); + }); + + it("6. Ё не мешает нормализации поискового запроса", () => { + expect(normalizeForSearch("ЧЁРНО") === normalizeForSearch("ЧЕРНО")).toBe( + true, + ); + expect(normalizeForSearch("ёлка") === normalizeForSearch("елка")).toBe( + true, + ); + }); + + it("7. Теги локалей для форматирования чисел корректны", () => { + expect(LOCALE_TAGS.ru).toBe("ru-RU"); + expect(LOCALE_TAGS.en).toBe("en-US"); + }); +}); diff --git a/web/src/lib/i18n/tools-i18n.test.ts b/web/src/lib/i18n/tools-i18n.test.ts new file mode 100644 index 0000000..37c0f8c --- /dev/null +++ b/web/src/lib/i18n/tools-i18n.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { TOOLS, type ToolEntry } from "../registry"; +import { en } from "./en"; +import { ru } from "./ru"; + +function toolKeys(tool: ToolEntry): { fields: string[]; groups: string[] } { + const fields: string[] = []; + for (const field of Object.values(tool.schema?.fields ?? {})) { + const label = (field as { spec?: { label?: string } }).spec?.label; + if (label) fields.push(label.replace(/^fields\./, "")); + } + const groups = (tool.schema?.layout?.groups ?? []).map((g) => + (g.title ?? "").replace(/^groups\./, ""), + ); + return { fields, groups }; +} + +describe("полнота словарей для нового registry", () => { + it("у каждого инструмента есть перевод ru с непустыми title/description", () => { + for (const tool of TOOLS) { + const strings = ru.tools[tool.id]; + expect(strings, `нет перевода ru для ${tool.id}`).toBeDefined(); + expect(strings?.title, `${tool.id}: title`).toBeTruthy(); + expect(strings?.description, `${tool.id}: description`).toBeTruthy(); + } + }); + + it("каждый label-ключ поля схемы переведён в fields обоих словарей", () => { + for (const tool of TOOLS) { + for (const key of toolKeys(tool).fields) { + expect(en.fields?.[key], `${tool.id}: ${key} в en`).toBeTruthy(); + expect(ru.fields?.[key], `${tool.id}: ${key} в ru`).toBeTruthy(); + } + } + }); + + it("каждый groups-ключ схемы переведён в groups обоих словарей", () => { + for (const tool of TOOLS) { + for (const key of toolKeys(tool).groups) { + expect(en.groups?.[key], `${tool.id}: ${key} в en`).toBeTruthy(); + expect(ru.groups?.[key], `${tool.id}: ${key} в ru`).toBeTruthy(); + } + } + }); + + it("в словарях нет лишних инструментов", () => { + const ids = new Set(TOOLS.map((tool) => tool.id)); + for (const dict of [ru.tools, en.tools]) { + for (const id of Object.keys(dict)) { + expect(ids.has(id), `лишний инструмент в словаре: ${id}`).toBe(true); + } + } + }); + + it("в fields и groups нет ключей, не используемых схемами", () => { + const usedFields = new Set(); + const usedGroups = new Set(); + for (const tool of TOOLS) { + const keys = toolKeys(tool); + keys.fields.forEach((k) => usedFields.add(k)); + keys.groups.forEach((k) => usedGroups.add(k)); + } + for (const dict of [en, ru]) { + for (const key of Object.keys(dict.fields ?? {})) { + expect(usedFields.has(key), `лишний fields-ключ: ${key}`).toBe(true); + } + for (const key of Object.keys(dict.groups ?? {})) { + expect(usedGroups.has(key), `лишний groups-ключ: ${key}`).toBe(true); + } + } + }); +});