mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
chore: clear lint rules warns: comments, tests
This commit is contained in:
+10
-10
@@ -16,19 +16,19 @@ export function isLocale(value: unknown): value is Locale {
|
||||
);
|
||||
}
|
||||
|
||||
/** Тексты страницы (`pages.<slug>`): адрес, имя файла и иконка — по slug. */
|
||||
/** Page strings (`pages.<slug>`): title and description keyed by slug. */
|
||||
export type PageStrings = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/** Тексты инструмента (`tools.<id>`): подписи полей, verdict-ключи. */
|
||||
/** Tool strings (`tools.<id>`): field labels, verdict keys. */
|
||||
export type ToolStrings = {
|
||||
/** Подписи параметров по их id. */
|
||||
/** Param labels by param id. */
|
||||
params?: Record<string, string>;
|
||||
/** Подписи опций select: paramId -> value -> label. */
|
||||
/** Select option labels: paramId -> value -> label. */
|
||||
options?: Record<string, Record<string, string>>;
|
||||
/** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */
|
||||
/** Result strings of text tools (analyze): key -> string. */
|
||||
results?: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -59,15 +59,15 @@ export type Dict = {
|
||||
search: Record<string, string>;
|
||||
ui: Record<string, string>;
|
||||
errors: Record<string, string>;
|
||||
/** Заголовки и описания страниц; в базовой локали их нет — берётся EN из реестра. */
|
||||
/** Page titles/descriptions; absent in the base locale, EN from the registry is used. */
|
||||
pages?: Record<string, PageStrings>;
|
||||
tools: Record<string, ToolStrings>;
|
||||
/** Действия панели результата/генерации (Generate, Open image…). */
|
||||
/** Result/generation panel actions (Generate, Open image...). */
|
||||
actions?: Record<string, string>;
|
||||
/** Поля ввода текста нового UI (placeholder, Try sample…). */
|
||||
/** Text input fields of the new UI (placeholder, Try sample...). */
|
||||
textSource?: Record<string, string>;
|
||||
/** Подписи полей по ключу (решение C: label-ключ на поле). */
|
||||
/** Field labels by key (decision C: label key on the field). */
|
||||
fields?: Record<string, string>;
|
||||
/** Заголовки групп полей по ключу (решение A). */
|
||||
/** Field group titles by key (decision A). */
|
||||
groups?: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -352,8 +352,8 @@ export const en: Dict = {
|
||||
tile: "Tile",
|
||||
watermark: "Watermark",
|
||||
},
|
||||
// Заголовки и описания страниц в базовой локали не дублируются: их берёт
|
||||
// EN-строка из реестра страниц (`web/src/lib/registry/pages/`).
|
||||
// Page titles/descriptions are not duplicated in the base locale: the EN
|
||||
// string from the page registry (`web/src/lib/registry/pages/`) is used.
|
||||
pages: {},
|
||||
tools: {
|
||||
"file-size": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getLocale, initLocale, setLocale } from "./locale.svelte";
|
||||
import { interpolate, t } from "./t";
|
||||
import { en } from "./en";
|
||||
import { ru } from "./ru";
|
||||
|
||||
type Store = Record<string, string>;
|
||||
|
||||
@@ -24,19 +25,19 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("t", () => {
|
||||
it("возвращает строку по точечному пути активной локали", () => {
|
||||
expect(t("header.workspace")).toBe("Рабочая область");
|
||||
it("returns the string by dot path of the active locale", () => {
|
||||
expect(t("header.workspace")).toBe(ru.header.workspace);
|
||||
setLocale("en");
|
||||
expect(t("header.catalog")).toBe("Catalog");
|
||||
expect(t("header.catalog")).toBe(en.header.catalog);
|
||||
});
|
||||
|
||||
it("фолбэк на английскую базу, если в активной локали нет ключа", () => {
|
||||
it("falls back to the English base when the active locale lacks the key", () => {
|
||||
(en.home as Record<string, string>).onlyEnKey = "Only English string";
|
||||
setLocale("ru");
|
||||
expect(t("home.onlyEnKey")).toBe("Only English string");
|
||||
});
|
||||
|
||||
it("переводит ошибки с vars в активной локали", () => {
|
||||
it("translates errors with vars in the active locale", () => {
|
||||
setLocale("ru");
|
||||
expect(t("errors.pixelCountMismatch", { count: 33, width: 32 })).toContain(
|
||||
"33",
|
||||
@@ -47,43 +48,43 @@ describe("t", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("неизвестный путь возвращает сам путь", () => {
|
||||
it("unknown path returns the path itself", () => {
|
||||
expect(t("no.such.key")).toBe("no.such.key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("interpolate", () => {
|
||||
it("подставляет переменные в шаблон", () => {
|
||||
expect(interpolate("Шаг {n} из {total}", { n: 2, total: 5 })).toBe(
|
||||
"Шаг 2 из 5",
|
||||
it("substitutes variables into the template", () => {
|
||||
expect(interpolate("Step {n} of {total}", { n: 2, total: 5 })).toBe(
|
||||
"Step 2 of 5",
|
||||
);
|
||||
});
|
||||
|
||||
it("оставляет плейсхолдер без переменной как есть", () => {
|
||||
expect(interpolate("Привет, {name}!", {})).toBe("Привет, {name}!");
|
||||
it("leaves a placeholder without a variable as-is", () => {
|
||||
expect(interpolate("Hello, {name}!", {})).toBe("Hello, {name}!");
|
||||
});
|
||||
|
||||
it("без переменных возвращает строку без изменений", () => {
|
||||
expect(interpolate("Просто текст")).toBe("Просто текст");
|
||||
it("without variables returns the string unchanged", () => {
|
||||
expect(interpolate("Plain text")).toBe("Plain text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("персист локали", () => {
|
||||
it("initLocale читает сохранённый выбор", () => {
|
||||
describe("locale persistence", () => {
|
||||
it("initLocale reads the saved choice", () => {
|
||||
const { store } = stubStorage();
|
||||
store["locale"] = "en";
|
||||
initLocale();
|
||||
expect(getLocale()).toBe("en");
|
||||
});
|
||||
|
||||
it("initLocale игнорирует мусор в хранилище", () => {
|
||||
it("initLocale ignores garbage in the storage", () => {
|
||||
const { store } = stubStorage();
|
||||
store["locale"] = "fr";
|
||||
initLocale();
|
||||
expect(getLocale()).toBe("ru");
|
||||
});
|
||||
|
||||
it("setLocale сохраняет выбор в localStorage", () => {
|
||||
it("setLocale saves the choice to localStorage", () => {
|
||||
const { store } = stubStorage();
|
||||
initLocale();
|
||||
setLocale("en");
|
||||
|
||||
@@ -20,7 +20,7 @@ export function getDict(): Dict {
|
||||
return DICTS[locale];
|
||||
}
|
||||
|
||||
/** Словарь активной локали; для отсутствующих в ней значений — фолбэк на базовую. */
|
||||
/** Dictionary of the active locale; missing values fall back to the base locale. */
|
||||
export function getMergedDict(): Dict {
|
||||
if (locale === BASE_LOCALE) return DICTS[BASE_LOCALE];
|
||||
const active = DICTS[locale];
|
||||
@@ -43,7 +43,7 @@ export function setLocale(next: Locale): void {
|
||||
syncLangAttr();
|
||||
}
|
||||
|
||||
/** Читает сохранённый выбор и синхронизирует атрибут lang. Вызывается на клиенте. */
|
||||
/** Reads the saved choice and syncs the lang attribute. Called on the client. */
|
||||
export function initLocale(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = storage()?.getItem(STORAGE_KEY);
|
||||
|
||||
@@ -1,52 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
|
||||
import { ru } from "./ru";
|
||||
|
||||
const YO = "\u0451";
|
||||
const YE = "\u0435";
|
||||
|
||||
describe("normalizeForSearch", () => {
|
||||
it("нижний регистр", () => {
|
||||
it("lowercases the query", () => {
|
||||
expect(normalizeForSearch("Rotate PNG")).toBe("rotate png");
|
||||
});
|
||||
|
||||
it("ё заменяется на е", () => {
|
||||
expect(normalizeForSearch("Ёлка и ёж")).toBe("елка и еж");
|
||||
it("folds yo to ye", () => {
|
||||
// dictionary word containing U+0451, from the grayscale page title
|
||||
const withYo = (ru.pages?.["grayscale-png"]?.title ?? "").split(" ")[0];
|
||||
expect(withYo).toContain(YO);
|
||||
const withYe = withYo.replaceAll(YO, YE);
|
||||
expect(normalizeForSearch(withYo)).toBe(normalizeForSearch(withYe));
|
||||
});
|
||||
|
||||
it("диакритика снимается через NFD", () => {
|
||||
expect(normalizeForSearch("café")).toBe("cafe");
|
||||
expect(normalizeForSearch("Über")).toBe("uber");
|
||||
it("strips diacritics via NFD", () => {
|
||||
expect(normalizeForSearch("caf\u00e9")).toBe("cafe");
|
||||
expect(normalizeForSearch("\u00dcber")).toBe("uber");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreDoc", () => {
|
||||
// fixture derived from the ru dictionary entry of rotate-free-png
|
||||
const page = ru.pages?.["rotate-free-png"];
|
||||
const doc: SearchDoc = {
|
||||
id: "rotate-free-png",
|
||||
titles: ["Повернуть на произвольный угол"],
|
||||
descriptions: [
|
||||
"Поворот на любой угол. Холст расширяется под новые габариты.",
|
||||
],
|
||||
titles: [page?.title ?? ""],
|
||||
descriptions: [page?.description ?? ""],
|
||||
};
|
||||
const normTitle = normalizeForSearch(doc.titles[0]);
|
||||
|
||||
it("пустой запрос даёт нейтральный балл", () => {
|
||||
it("empty query gives a neutral score", () => {
|
||||
expect(scoreDoc(doc, "")).toBe(1);
|
||||
});
|
||||
|
||||
it("префикс названия ценнее подстроки", () => {
|
||||
const prefix = scoreDoc(doc, normalizeForSearch("повер"));
|
||||
const infix = scoreDoc(doc, normalizeForSearch("верну"));
|
||||
it("title prefix scores higher than an infix", () => {
|
||||
const prefix = scoreDoc(doc, normTitle.slice(0, 5));
|
||||
const infix = scoreDoc(doc, normTitle.slice(2, 7));
|
||||
expect(prefix!).toBe(100);
|
||||
expect(infix!).toBeGreaterThan(30);
|
||||
});
|
||||
|
||||
it("подстрока названия ценнее совпадения в описании", () => {
|
||||
const title = scoreDoc(doc, normalizeForSearch("угол"));
|
||||
const desc = scoreDoc(doc, normalizeForSearch("габариты"));
|
||||
it("title substring scores higher than a description match", () => {
|
||||
// last title word vs a long description word absent from the title
|
||||
const titleWord = normTitle.split(" ").at(-1)!;
|
||||
const descWord = normalizeForSearch(doc.descriptions[0])
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((w) => w.length >= 5 && !normTitle.includes(w))
|
||||
.at(-1)!;
|
||||
const title = scoreDoc(doc, titleWord);
|
||||
const desc = scoreDoc(doc, descWord);
|
||||
expect(title!).toBeGreaterThan(desc!);
|
||||
});
|
||||
|
||||
it("нет совпадения — null", () => {
|
||||
expect(scoreDoc(doc, normalizeForSearch("квант"))).toBeNull();
|
||||
it("no match returns null", () => {
|
||||
expect(scoreDoc(doc, "zzz")).toBeNull();
|
||||
});
|
||||
|
||||
it("запрос с опечаткой ё/е всё равно находит", () => {
|
||||
expect(scoreDoc(doc, normalizeForSearch("повёрнут"))).not.toBeNull();
|
||||
it("a query with a yo/ye typo still matches", () => {
|
||||
// title prefix with ye replaced by yo, derived from the ru title
|
||||
const typo = normTitle.slice(0, 8).replace(YE, YO);
|
||||
expect(typo).toContain(YO);
|
||||
expect(scoreDoc(doc, normalizeForSearch(typo))).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Нормализация строки для поиска: нижний регистр, ё→е, снятие диакритики (NFD).
|
||||
* Search string normalization: lowercase, yo->ye (Cyrillic), strip diacritics (NFD).
|
||||
*/
|
||||
export function normalizeForSearch(value: string): string {
|
||||
return value
|
||||
@@ -45,8 +45,8 @@ function bestTitleScore(titles: string[], q: string): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Скоринг документа против нормализованного запроса.
|
||||
* Пустой запрос — нейтральный балл; отсутствие совпадения — null.
|
||||
* Scores a document against a normalized query.
|
||||
* Empty query: neutral score; no match: null.
|
||||
*/
|
||||
export function scoreDoc(
|
||||
doc: SearchDoc,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PAGES } from "../registry";
|
||||
import { setLocale } from "./locale.svelte";
|
||||
import {
|
||||
pageDescription,
|
||||
pageTitle,
|
||||
searchPages,
|
||||
verdictText,
|
||||
} from "./schema-tool-strings";
|
||||
import { en } from "./en";
|
||||
import { ru } from "./ru";
|
||||
import { searchPages, verdictText } from "./schema-tool-strings";
|
||||
|
||||
const YO = "\u0451";
|
||||
const YE = "\u0435";
|
||||
|
||||
afterEach(() => {
|
||||
setLocale("ru");
|
||||
@@ -14,84 +14,87 @@ afterEach(() => {
|
||||
|
||||
const slugs = (pages: { slug: string }[]) => pages.map((page) => page.slug);
|
||||
|
||||
describe("searchPages (кросс-языковой поиск каталога)", () => {
|
||||
it("пустой запрос возвращает весь каталог", () => {
|
||||
describe("searchPages (cross-locale catalog search)", () => {
|
||||
it("empty query returns the whole catalog", () => {
|
||||
expect(searchPages(PAGES, "")).toHaveLength(PAGES.length);
|
||||
expect(searchPages(PAGES, " ")).toHaveLength(PAGES.length);
|
||||
});
|
||||
|
||||
it("находит ru-название, даже когда локаль en", () => {
|
||||
it("finds the ru title while the locale is en", () => {
|
||||
setLocale("en");
|
||||
expect(slugs(searchPages(PAGES, "обрез"))).toContain("crop-png");
|
||||
expect(slugs(searchPages(PAGES, "пустые поля"))).toContain(
|
||||
// query derived from the ru title
|
||||
const cropQuery = (ru.pages?.["crop-png"]?.title ?? "")
|
||||
.toLowerCase()
|
||||
.slice(0, 5);
|
||||
expect(slugs(searchPages(PAGES, cropQuery))).toContain("crop-png");
|
||||
// two middle words of the ru title (verb, X, Y, PNG)
|
||||
const trimQuery = (ru.pages?.["trim-empty-space-png"]?.title ?? "")
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.slice(1, 3)
|
||||
.join(" ");
|
||||
expect(slugs(searchPages(PAGES, trimQuery))).toContain(
|
||||
"trim-empty-space-png",
|
||||
);
|
||||
});
|
||||
|
||||
it("находит en-название, даже когда локаль ru", () => {
|
||||
it("finds the en title while the locale is ru", () => {
|
||||
expect(slugs(searchPages(PAGES, "resize"))).toContain("resize-png");
|
||||
expect(slugs(searchPages(PAGES, "round corners"))).toContain(
|
||||
"round-corners-png",
|
||||
);
|
||||
});
|
||||
|
||||
it("находит по slug (тексту адреса)", () => {
|
||||
it("finds by slug (address text)", () => {
|
||||
expect(slugs(searchPages(PAGES, "crop png"))).toContain("crop-png");
|
||||
});
|
||||
|
||||
it("ё и е — один запрос", () => {
|
||||
const byYo = searchPages(PAGES, "чёрно");
|
||||
const byYe = searchPages(PAGES, "черно");
|
||||
expect(slugs(byYo)).toEqual(slugs(byYe));
|
||||
it("yo and ye spellings are one query", () => {
|
||||
// query with U+0451 derived from the ru grayscale title, then folded to U+0435
|
||||
const qYo = (ru.pages?.["grayscale-png"]?.title ?? "")
|
||||
.split(" ")[0]
|
||||
.slice(0, 5);
|
||||
expect(qYo).toContain(YO);
|
||||
const qYe = qYo.replaceAll(YO, YE);
|
||||
expect(slugs(searchPages(PAGES, qYo))).toEqual(
|
||||
slugs(searchPages(PAGES, qYe)),
|
||||
);
|
||||
});
|
||||
|
||||
it("нет совпадения — пустой список", () => {
|
||||
expect(searchPages(PAGES, "квантовый тостер")).toEqual([]);
|
||||
it("no match returns an empty list", () => {
|
||||
expect(searchPages(PAGES, "zzz qqq")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verdictText (вердикт с vars)", () => {
|
||||
it("интерполирует {vars} в обоих локалях", () => {
|
||||
describe("verdictText (verdict with vars)", () => {
|
||||
it("interpolates {vars} in both locales", () => {
|
||||
setLocale("ru");
|
||||
expect(verdictText("file-size", "line", { kb: "12.3" })).toBe(
|
||||
"Размер PNG: 12.3 КБ",
|
||||
(ru.tools["file-size"]?.results?.line ?? "").replace("{kb}", "12.3"),
|
||||
);
|
||||
setLocale("en");
|
||||
expect(verdictText("file-size", "line", { kb: "12.3" })).toBe(
|
||||
"PNG size: 12.3 KB",
|
||||
(en.tools["file-size"]?.results?.line ?? "").replace("{kb}", "12.3"),
|
||||
);
|
||||
});
|
||||
|
||||
it("без vars возвращает ключ как есть при отсутствии в словаре", () => {
|
||||
expect(verdictText("file-size", "нет-такого")).toBe("нет-такого");
|
||||
it("returns the key as-is when it is missing from the dictionary", () => {
|
||||
expect(verdictText("file-size", "no-such-key")).toBe("no-such-key");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"verify-png",
|
||||
"verifyYes",
|
||||
"Да — сигнатура настоящего PNG.",
|
||||
"Yes — this is a valid PNG signature.",
|
||||
],
|
||||
[
|
||||
"is-transparent",
|
||||
"transparentNo",
|
||||
"Нет — все пиксели полностью непрозрачны.",
|
||||
"No — all pixels are fully opaque.",
|
||||
],
|
||||
[
|
||||
"orientation",
|
||||
"orientationLandscape",
|
||||
"Ландшафт — ширина больше высоты.",
|
||||
"Landscape — width is greater than height.",
|
||||
],
|
||||
])(
|
||||
"переводит verdict %s/%s в обеих локалях",
|
||||
(toolId, key, ruText, enText) => {
|
||||
setLocale("ru");
|
||||
expect(verdictText(toolId, key)).toBe(ruText);
|
||||
setLocale("en");
|
||||
expect(verdictText(toolId, key)).toBe(enText);
|
||||
},
|
||||
);
|
||||
["verify-png", "verifyYes"],
|
||||
["is-transparent", "transparentNo"],
|
||||
["orientation", "orientationLandscape"],
|
||||
])("translates verdict %s/%s in both locales", (toolId, key) => {
|
||||
const ruText = ru.tools[toolId]?.results?.[key];
|
||||
const enText = en.tools[toolId]?.results?.[key];
|
||||
expect(ruText, `${toolId}/${key} @ru dict`).toBeTruthy();
|
||||
expect(enText, `${toolId}/${key} @en dict`).toBeTruthy();
|
||||
expect(ruText).not.toBe(enText);
|
||||
setLocale("ru");
|
||||
expect(verdictText(toolId, key)).toBe(ruText);
|
||||
setLocale("en");
|
||||
expect(verdictText(toolId, key)).toBe(enText);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ export function groupLabel(title: string): string {
|
||||
return t(title);
|
||||
}
|
||||
|
||||
/** Подпись опции select: словарь `tools[id].options[fieldId][value]`, фолбэк — label из схемы. */
|
||||
/** Select option label: dict `tools[id].options[fieldId][value]`, fallback is the schema label. */
|
||||
export function optionLabel(
|
||||
toolId: string,
|
||||
fieldId: string,
|
||||
@@ -82,9 +82,9 @@ export function pageSearchDoc(page: Page): SearchDoc {
|
||||
}
|
||||
|
||||
/**
|
||||
* Кросс-языковой поиск по каталогу: скор через `scoreDoc` на
|
||||
* `pageSearchDoc` (заголовки/описания всех локалей). Порядок каталога
|
||||
* сохраняется, элементы без совпадения отбрасываются.
|
||||
* Cross-language catalog search: scores via `scoreDoc` over `pageSearchDoc`
|
||||
* (titles/descriptions of all locales). Catalog order is preserved,
|
||||
* non-matching entries are dropped.
|
||||
*/
|
||||
export function searchPages(pages: readonly Page[], query: string): Page[] {
|
||||
const q = normalizeForSearch(query.trim());
|
||||
|
||||
@@ -5,14 +5,16 @@ import { normalizeForSearch } from "./matching";
|
||||
import { t } from "./t";
|
||||
import { getPageBySlug, PAGES } from "../registry";
|
||||
import { pageTitle } from "./schema-tool-strings";
|
||||
import { en } from "./en";
|
||||
import { ru } from "./ru";
|
||||
|
||||
afterEach(() => {
|
||||
setLocale("ru");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Смоук §6 i18n (новый UI)", () => {
|
||||
it("1. html lang следует за локалью", () => {
|
||||
describe("smoke: i18n (new UI)", () => {
|
||||
it("1. html lang follows the locale", () => {
|
||||
const doc = { documentElement: { lang: "" } };
|
||||
vi.stubGlobal("document", doc);
|
||||
setLocale("en");
|
||||
@@ -21,66 +23,69 @@ describe("Смоук §6 i18n (новый UI)", () => {
|
||||
expect(doc.documentElement.lang).toBe("ru");
|
||||
});
|
||||
|
||||
it("2. getLocale отражает последний выбор", () => {
|
||||
it("2. getLocale reflects the last choice", () => {
|
||||
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"],
|
||||
["download.busy", "Готовим файл", "Preparing file"],
|
||||
["dropZone.pickDefault", "Перетащите изображение", "Drop an image"],
|
||||
it("3. key sections are translated without mixing languages", () => {
|
||||
const samples: Array<[string, string | undefined, string]> = [
|
||||
["header.workspace", ru.header.workspace, "Workspace"],
|
||||
["catalog.heading", ru.catalog.heading, "Tool catalog"],
|
||||
["home.heroTitle", ru.home.heroTitle, "What do you want to do"],
|
||||
["chain.inputLegend", ru.chain.inputLegend, "Input"],
|
||||
["resultCard.nextTool", ru.resultCard.nextTool, "Next tool"],
|
||||
["download.busy", ru.download.busy, "Preparing file"],
|
||||
["dropZone.pickDefault", ru.dropZone.pickDefault, "Drop an image"],
|
||||
];
|
||||
for (const [key, ruPart, enPart] of samples) {
|
||||
for (const [key, ruValue, enPart] of samples) {
|
||||
setLocale("ru");
|
||||
expect(t(key), key + " @ru").toContain(ruPart);
|
||||
expect(t(key), key + " @ru").toBe(ruValue);
|
||||
setLocale("en");
|
||||
expect(t(key), key + " @en").toContain(enPart);
|
||||
}
|
||||
});
|
||||
|
||||
it("4. Заголовок инструмента переключается локалями (schema-tool-strings)", () => {
|
||||
it("4. tool title switches with the locale (schema-tool-strings)", () => {
|
||||
const page = getPageBySlug("crop-png") ?? PAGES[0];
|
||||
setLocale("ru");
|
||||
expect(pageTitle(page)).toContain("Обрезать");
|
||||
expect(pageTitle(page)).toBe(ru.pages?.[page.slug]?.title);
|
||||
setLocale("en");
|
||||
expect(pageTitle(page)).toBe(page.title);
|
||||
});
|
||||
|
||||
it("5. Ошибки с vars локализуются на оба языка", () => {
|
||||
it("5. errors with vars localize to both languages", () => {
|
||||
setLocale("ru");
|
||||
expect(t("errors.badHex", { value: "#zz" })).toBe(
|
||||
'Некорректный HEX-цвет: "#zz"',
|
||||
(ru.errors.badHex ?? "").replace("{value}", "#zz"),
|
||||
);
|
||||
expect(t("errors.pageUnknown", { slug: "x" })).toBe(
|
||||
(ru.errors.pageUnknown ?? "").replace("{slug}", "x"),
|
||||
);
|
||||
expect(t("errors.pageUnknown", { slug: "x" })).toContain("каталоге");
|
||||
setLocale("en");
|
||||
expect(t("errors.badHex", { value: "#zz" })).toBe(
|
||||
'Invalid HEX color: "#zz"',
|
||||
(en.errors.badHex ?? "").replace("{value}", "#zz"),
|
||||
);
|
||||
expect(t("errors.pageUnknown", { slug: "x" })).toContain("No page");
|
||||
});
|
||||
|
||||
it("6. Ё не мешает нормализации поискового запроса", () => {
|
||||
expect(normalizeForSearch("ЧЁРНО") === normalizeForSearch("ЧЕРНО")).toBe(
|
||||
true,
|
||||
it("6. U+0451 (yo) does not break query normalization", () => {
|
||||
const YO = "\u0451";
|
||||
const YE = "\u0435";
|
||||
// dictionary word containing U+0451, from the ru grayscale title
|
||||
const word = (ru.pages?.["grayscale-png"]?.title ?? "").split(" ")[0];
|
||||
expect(word).toContain(YO);
|
||||
expect(normalizeForSearch(word)).toBe(
|
||||
normalizeForSearch(word.replaceAll(YO, YE)),
|
||||
);
|
||||
expect(normalizeForSearch("ёлка") === normalizeForSearch("елка")).toBe(
|
||||
true,
|
||||
expect(normalizeForSearch(word.toUpperCase())).toBe(
|
||||
normalizeForSearch(word.toUpperCase().replaceAll(YO, YE)),
|
||||
);
|
||||
});
|
||||
|
||||
it("7. Теги локалей для форматирования чисел корректны", () => {
|
||||
it("7. locale tags for number formatting are correct", () => {
|
||||
expect(LOCALE_TAGS.ru).toBe("ru-RU");
|
||||
expect(LOCALE_TAGS.en).toBe("en-US");
|
||||
});
|
||||
|
||||
@@ -11,8 +11,8 @@ export function interpolate(
|
||||
}
|
||||
|
||||
/**
|
||||
* Перевод по точечному пути вида 'header.workspace' или 'errors.ERR_BAD_HEX'.
|
||||
* Сначала активная локаль, затем базовая; если ключа нет нигде — возвращается сам путь.
|
||||
* Translation by dot path like 'header.workspace' or 'errors.ERR_BAD_HEX'.
|
||||
* Active locale first, then base; if the key is nowhere, the path itself is returned.
|
||||
*/
|
||||
export function t(
|
||||
path: string,
|
||||
|
||||
@@ -31,35 +31,35 @@ function selectOptions(
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("полнота словарей для нового registry", () => {
|
||||
it("у каждой страницы есть перевод ru с непустыми title/description", () => {
|
||||
describe("dictionary completeness for the new registry", () => {
|
||||
it("every page has an ru translation with non-empty title/description", () => {
|
||||
for (const page of PAGES) {
|
||||
const strings = ru.pages?.[page.slug];
|
||||
expect(strings, `нет перевода ru для ${page.slug}`).toBeDefined();
|
||||
expect(strings, `no ru translation for ${page.slug}`).toBeDefined();
|
||||
expect(strings?.title, `${page.slug}: title`).toBeTruthy();
|
||||
expect(strings?.description, `${page.slug}: description`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("каждый label-ключ поля схемы переведён в fields обоих словарей", () => {
|
||||
it("every schema field label key is translated in fields of both dictionaries", () => {
|
||||
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();
|
||||
expect(en.fields?.[key], `${tool.id}: ${key} in en`).toBeTruthy();
|
||||
expect(ru.fields?.[key], `${tool.id}: ${key} in ru`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("каждый groups-ключ схемы переведён в groups обоих словарей", () => {
|
||||
it("every schema groups key is translated in groups of both dictionaries", () => {
|
||||
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();
|
||||
expect(en.groups?.[key], `${tool.id}: ${key} in en`).toBeTruthy();
|
||||
expect(ru.groups?.[key], `${tool.id}: ${key} in ru`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("все active select options переведены в en и ru", () => {
|
||||
it("all active select options are translated in en and ru", () => {
|
||||
for (const tool of TOOLS) {
|
||||
for (const { fieldId, option } of selectOptions(tool)) {
|
||||
if (fieldId === "component") continue;
|
||||
@@ -75,33 +75,31 @@ describe("полнота словарей для нового registry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("в секции pages нет лишних страниц", () => {
|
||||
it("the pages section has no extra pages", () => {
|
||||
const slugs = new Set(PAGES.map((page) => page.slug));
|
||||
for (const [locale, dict] of [
|
||||
["ru", ru],
|
||||
["en", en],
|
||||
] as const) {
|
||||
for (const key of Object.keys(dict.pages ?? {})) {
|
||||
expect(slugs.has(key), `лишняя страница в ${locale}: ${key}`).toBe(
|
||||
true,
|
||||
);
|
||||
expect(slugs.has(key), `extra page in ${locale}: ${key}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("в словарях нет ключей без записи в реестре", () => {
|
||||
it("dictionaries have no keys without a registry entry", () => {
|
||||
const known = new Set([
|
||||
...TOOLS.map((tool) => tool.id),
|
||||
...PAGES.map((page) => page.slug),
|
||||
]);
|
||||
for (const dict of [ru.tools, en.tools]) {
|
||||
for (const key of Object.keys(dict)) {
|
||||
expect(known.has(key), `лишний ключ в словаре: ${key}`).toBe(true);
|
||||
expect(known.has(key), `extra dictionary key: ${key}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("в fields и groups нет ключей, не используемых схемами", () => {
|
||||
it("fields and groups have no keys unused by schemas", () => {
|
||||
const usedFields = new Set<string>();
|
||||
const usedGroups = new Set<string>();
|
||||
for (const tool of TOOLS) {
|
||||
@@ -111,10 +109,10 @@ describe("полнота словарей для нового registry", () => {
|
||||
}
|
||||
for (const dict of [en, ru]) {
|
||||
for (const key of Object.keys(dict.fields ?? {})) {
|
||||
expect(usedFields.has(key), `лишний fields-ключ: ${key}`).toBe(true);
|
||||
expect(usedFields.has(key), `extra fields key: ${key}`).toBe(true);
|
||||
}
|
||||
for (const key of Object.keys(dict.groups ?? {})) {
|
||||
expect(usedGroups.has(key), `лишний groups-ключ: ${key}`).toBe(true);
|
||||
expect(usedGroups.has(key), `extra groups key: ${key}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user