chore: copy i18n to v1 for separate

This commit is contained in:
2026-09-12 19:19:22 +05:00
parent 9fdd960d81
commit de2a229545
12 changed files with 2213 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
import type { CategoryId } from "../categories";
export const LOCALES = ["ru", "en"] as const;
export type Locale = (typeof LOCALES)[number];
export const BASE_LOCALE: Locale = "en";
export const LOCALE_TAGS: Record<Locale, string> = {
ru: "ru-RU",
en: "en-US",
};
export function isLocale(value: unknown): value is Locale {
return (
typeof value === "string" && (LOCALES as readonly string[]).includes(value)
);
}
export type ToolStrings = {
title?: string;
description?: string;
/** Подписи параметров по их id. */
params?: Record<string, string>;
/** Подписи опций select: paramId -> value -> label. */
options?: Record<string, Record<string, string>>;
/** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */
results?: Record<string, string>;
};
export type HeaderStrings = {
workspace: string;
catalog: string;
sectionsAria: string;
footerNote: string;
};
export type Dict = {
header: HeaderStrings;
categories: Record<CategoryId, string>;
home: Record<string, string>;
catalog: Record<string, string>;
toolPage: Record<string, string>;
chain: Record<string, string>;
sourceCard: Record<string, string>;
resultCard: Record<string, string>;
paramsCard: Record<string, string>;
textInput: Record<string, string>;
textResult: Record<string, string>;
download: Record<string, string>;
infoPanel: Record<string, string>;
dropZone: Record<string, string>;
search: Record<string, string>;
ui: Record<string, string>;
errors: Record<string, string>;
tools: Record<string, ToolStrings>;
};
+188
View File
@@ -0,0 +1,188 @@
import type { Dict } from "./dict";
export const en: Dict = {
header: {
workspace: "Workspace",
catalog: "Catalog",
sectionsAria: "Sections",
footerNote:
"All operations run locally in your browser — your files are never uploaded anywhere.",
},
categories: {
convert: "Convert",
alpha: "Transparency",
color: "Color",
geometry: "Geometry",
filters: "Filters",
text: "Text",
analyze: "Analyze",
generate: "Generate",
},
home: {
defaultTitle: "easy-png-tools — PNG utilities right in your browser",
heroTitle: "What do you want to do with the image?",
heroLead: "Find a tool — everything runs locally in your browser.",
restoreLast: "↩ Restore last: {title}",
changeTool: "← Change tool",
},
catalog: {
pageTitle: "All tools — easy-png-tools",
metaDescription:
"Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.",
heading: "Tool catalog",
lead: "{count} utilities for working with PNG. Everything runs locally in your browser.",
},
toolPage: {
fallbackTitle: "Tool",
legendSource: "Source",
legendSummary: "Summary",
legendResult: "Result",
legendParams: "Parameters",
stepHeading: "Step {n}",
removeStepAria: "Remove step",
stepError: "Step {n} ({title}): {msg}",
},
chain: {
stepLabel: "Step {n}: {title}",
inputLegend: "Input",
resultLegend: "Result",
paramsLegend: "Parameters",
busyTitle: "Processing…",
busyHint: "Running chain step",
removeStepAria: "Remove step",
},
sourceCard: {
replaceImage: "Replace image",
},
resultCard: {
emptyTitle: "The result will appear here",
emptyHint: "First upload a source image on the left",
processingTitle: "Processing…",
processingHint: "The image is being processed, this will take a moment",
recalc: "Recalculating…",
nextTool: "⛓ Next tool",
breakChain: "✂ Break the chain",
},
paramsCard: {
noParams: "This tool has no parameters — the result is ready as is.",
},
textInput: {
heading: "Text",
placeholder: "Paste data here",
aria: "Text data",
decode: "Decode",
},
textResult: {
outputAria: "Text result",
copied: "Copied",
copy: "Copy",
downloadTxt: "Download .txt",
},
download: {
busy: "Preparing file…",
file: "Download .{ext}",
},
infoPanel: {
dimensions: "Dimensions",
alpha: "Alpha channel",
alphaYes: "yes — there are semi-transparent pixels",
alphaNo: "no",
colorCount: "Unique colors (RGBA)",
},
dropZone: {
pickDefault: "Drop an image here or click to choose a file",
overlayDefault: "Release the file to replace the image",
},
search: {
placeholder: "Find a tool…",
aria: "Search tools",
nothingFound: "Nothing found — try another word.",
},
ui: {
showMask: "Show mask",
decrease: "Decrease",
increase: "Increase",
reset: "Reset",
pipette: "Eyedropper",
themeLight: "Light theme",
themeDark: "Dark theme",
overlayTitle: "Watermark",
overlayDrop: "Drop a watermark PNG or click",
overlayRemove: "Remove watermark",
},
errors: {
noImageRun: "This tool does not process images",
sourceRequired: "Upload an image first",
textRequired: "Enter text first",
workerFailed: "Worker execution failed",
workerUnavailable: "Worker is unavailable",
notFound: "Tool not found",
noWatermark: "Pick a watermark image first",
badTransform: "Degenerate transformation matrix",
skewAngle: "Skew angles cannot be 90° or -90°",
badHex: 'Invalid HEX color: "{value}"',
radiusInt: "Radius must be a non-negative integer",
kernelSize: "Kernel does not match the image dimensions",
sizeInt: "Width and height must be integers ≥ 1",
cropBounds: "Crop area does not intersect the image",
noCanvasCtx: "Canvas 2D context is unavailable in this environment",
badBase64: "Expected a base64 string or a data-uri of an image",
qualityRange: "quality must be within 0..1",
svgSize: "Could not determine SVG dimensions",
svgLoad: "Failed to load SVG — check the markup",
encodeUnsupported: "The browser does not support encoding to {mime}",
unsupportedFile:
"Unsupported file format ({type}). Supported formats are PNG, JPEG, WebP, GIF and BMP.",
widthInt: "Image width must be an integer ≥ 1",
noHexPixels: "No hex pixel values found",
badPixelToken:
"Each pixel must be 8 hex characters RRGGBBAA, separated by spaces",
pixelCountMismatch:
"Pixel count ({count}) is not divisible by width {width} without a remainder",
toolNotFound: 'Tool "{id}" not found',
badJson: "The file is not valid JSON",
badPipelineShape: "The file structure does not look like a chain of steps",
pipelineVersion: "Unsupported chain version: {version}",
noSteps: "The file has no list of steps",
paramNumber: 'Parameter "{id}" must be a number',
paramString: 'Parameter "{id}" must be a string',
paramBool: 'Parameter "{id}" must be a checkbox value',
resizeSize: "Width and/or height must be positive",
cropSize: "Crop width and height must be positive",
sizePositive: "Dimensions must be positive and finite",
tooManyParts: "Too many parts ({count}). Maximum is 1000.",
},
tools: {
"png-file-size": {
results: {
line: "PNG size: {kb} KB",
},
},
"verify-is-png": {
results: {
verifyYes: "Yes — this is a valid PNG signature.",
verifyNo: "No — the signature does not match a PNG file.",
},
},
"png-is-grayscale": {
results: {
grayscaleYes: "Yes — all pixels are shades of gray.",
grayscaleNo: "No — colored pixels were found.",
},
},
"png-is-transparent": {
results: {
transparentYes:
"Yes — there are transparent or semi-transparent pixels.",
transparentNo: "No — all pixels are fully opaque.",
},
},
"png-orientation": {
results: {
orientationPortrait: "Portrait — height is greater than width.",
orientationLandscape: "Landscape — width is greater than height.",
orientationSquare: "Square — the sides are equal.",
},
},
},
};
+82
View File
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getLocale, initLocale, setLocale } from "./locale.svelte";
import { interpolate, t } from "./t";
import { en } from "./en";
type Store = Record<string, string>;
function stubStorage(): { store: Store } {
const store: Store = {};
vi.stubGlobal("localStorage", {
getItem: (k: string) => (k in store ? store[k] : null),
setItem: (k: string, v: string) => {
store[k] = v;
},
});
vi.stubGlobal("window", {});
return { store };
}
beforeEach(() => {
setLocale("ru");
delete (en.home as Record<string, string>).onlyEnKey;
vi.unstubAllGlobals();
});
describe("t", () => {
it("возвращает строку по точечному пути активной локали", () => {
expect(t("header.workspace")).toBe("Рабочая область");
setLocale("en");
expect(t("header.catalog")).toBe("Catalog");
});
it("фолбэк на английскую базу, если в активной локали нет ключа", () => {
(en.home as Record<string, string>).onlyEnKey = "Only English string";
setLocale("ru");
expect(t("home.onlyEnKey")).toBe("Only English string");
});
it("неизвестный путь возвращает сам путь", () => {
expect(t("no.such.key")).toBe("no.such.key");
});
});
describe("interpolate", () => {
it("подставляет переменные в шаблон", () => {
expect(interpolate("Шаг {n} из {total}", { n: 2, total: 5 })).toBe(
"Шаг 2 из 5",
);
});
it("оставляет плейсхолдер без переменной как есть", () => {
expect(interpolate("Привет, {name}!", {})).toBe("Привет, {name}!");
});
it("без переменных возвращает строку без изменений", () => {
expect(interpolate("Просто текст")).toBe("Просто текст");
});
});
describe("персист локали", () => {
it("initLocale читает сохранённый выбор", () => {
const { store } = stubStorage();
store["locale"] = "en";
initLocale();
expect(getLocale()).toBe("en");
});
it("initLocale игнорирует мусор в хранилище", () => {
const { store } = stubStorage();
store["locale"] = "fr";
initLocale();
expect(getLocale()).toBe("ru");
});
it("setLocale сохраняет выбор в localStorage", () => {
const { store } = stubStorage();
initLocale();
setLocale("en");
expect(store["locale"]).toBe("en");
expect(getLocale()).toBe("en");
});
});
+58
View File
@@ -0,0 +1,58 @@
import { BASE_LOCALE, isLocale, type Dict, type Locale } from "./dict";
import { ru } from "./ru";
import { en } from "./en";
const STORAGE_KEY = "locale";
const DICTS: Record<Locale, Dict> = { ru, en };
let locale = $state<Locale>(BASE_LOCALE);
function storage(): Storage | null {
return typeof localStorage === "undefined" ? null : localStorage;
}
export function getLocale(): Locale {
return locale;
}
export function getDict(): Dict {
return DICTS[locale];
}
/** Словарь активной локали; для отсутствующих в ней значений — фолбэк на базовую. */
export function getMergedDict(): Dict {
if (locale === BASE_LOCALE) return DICTS[BASE_LOCALE];
const active = DICTS[locale];
const base = DICTS[BASE_LOCALE];
return new Proxy(base, {
get(_target, section: string) {
const a = (active as unknown as Record<string, unknown>)[section];
const b = (base as unknown as Record<string, unknown>)[section];
if (a && b && typeof a === "object" && typeof b === "object") {
return { ...(b as object), ...(a as object) };
}
return a ?? b;
},
}) as Dict;
}
export function setLocale(next: Locale): void {
locale = next;
storage()?.setItem(STORAGE_KEY, next);
syncLangAttr();
}
/** Читает сохранённый выбор и синхронизирует атрибут lang. Вызывается на клиенте. */
export function initLocale(): void {
if (typeof window === "undefined") return;
const saved = storage()?.getItem(STORAGE_KEY);
if (isLocale(saved)) locale = saved;
syncLangAttr();
}
function syncLangAttr(): void {
if (typeof document !== "undefined") {
document.documentElement.lang = locale;
}
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { TOOLS } from "../registry";
import { setLocale } from "./locale.svelte";
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
import { toolSearchDoc } from "./tool-strings";
describe("normalizeForSearch", () => {
it("нижний регистр", () => {
expect(normalizeForSearch("Rotate PNG")).toBe("rotate png");
});
it("ё заменяется на е", () => {
expect(normalizeForSearch("Ёлка и ёж")).toBe("елка и еж");
});
it("диакритика снимается через NFD", () => {
expect(normalizeForSearch("café")).toBe("cafe");
expect(normalizeForSearch("Über")).toBe("uber");
});
});
describe("scoreDoc", () => {
const doc: SearchDoc = {
id: "rotate-free-png",
titles: ["Повернуть на произвольный угол"],
descriptions: [
"Поворот на любой угол. Холст расширяется под новые габариты.",
],
};
it("пустой запрос даёт нейтральный балл", () => {
expect(scoreDoc(doc, "")).toBe(1);
});
it("префикс названия ценнее подстроки", () => {
const prefix = scoreDoc(doc, normalizeForSearch("повер"));
const infix = scoreDoc(doc, normalizeForSearch("верну"));
expect(prefix!).toBe(100);
expect(infix!).toBeGreaterThan(30);
});
it("подстрока названия ценнее совпадения в описании", () => {
const title = scoreDoc(doc, normalizeForSearch("угол"));
const desc = scoreDoc(doc, normalizeForSearch("габариты"));
expect(title!).toBeGreaterThan(desc!);
});
it("нет совпадения — null", () => {
expect(scoreDoc(doc, normalizeForSearch("квант"))).toBeNull();
});
it("запрос с опечаткой ё/е всё равно находит", () => {
expect(scoreDoc(doc, normalizeForSearch("повёрнут"))).not.toBeNull();
});
});
describe("кросс-языковой поиск на реальном реестре", () => {
it("английский запрос находит инструмент при русской локали", () => {
setLocale("ru");
const hits = TOOLS.filter((tool) => {
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch("rotate"));
return s !== null && s > 0;
}).map((tool) => tool.id);
expect(hits).toContain("rotate-png");
expect(hits).toContain("rotate-free-png");
});
it("русский запрос находит инструмент при английской локали", () => {
setLocale("en");
try {
for (const q of ["повернуть", "пово"]) {
const hits = TOOLS.filter((tool) => {
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q));
return s !== null && s > 0;
}).map((tool) => tool.id);
expect(hits, `запрос «${q}» при en-локали`).toContain("rotate-png");
expect(hits, `запрос «${q}» при en-локали`).toContain(
"rotate-free-png",
);
}
} finally {
setLocale("ru");
}
});
it("поиск работает и по описанию, не только по названию", () => {
setLocale("en");
try {
const hits = TOOLS.filter((tool) => {
const s = scoreDoc(
toolSearchDoc(tool),
normalizeForSearch("полупрозрачные"),
);
return s !== null && s > 0;
}).map((tool) => tool.id);
expect(hits).toContain("png-is-transparent");
} finally {
setLocale("ru");
}
});
});
+69
View File
@@ -0,0 +1,69 @@
/**
* Нормализация строки для поиска: нижний регистр, ё→е, снятие диакритики (NFD).
*/
export function normalizeForSearch(value: string): string {
return value
.toLowerCase()
.replaceAll("ё", "е")
.normalize("NFD")
.replace(/\p{M}/gu, "");
}
export type SearchDoc = {
id: string;
titles: string[];
descriptions: string[];
};
const SUBSEQUENCE_BASE = 20;
function bestTitleScore(titles: string[], q: string): number | null {
let best: number | null = null;
for (const raw of titles) {
const title = normalizeForSearch(raw);
if (title.startsWith(q)) return 100;
const at = title.indexOf(q);
if (at >= 0) {
const s = 80 - Math.min(at, 40);
if (best === null || s > best) best = s;
continue;
}
let pos = 0;
let sub = SUBSEQUENCE_BASE;
for (const ch of q) {
pos = title.indexOf(ch, pos);
if (pos < 0) {
sub = -1;
break;
}
sub -= 1;
pos += 1;
}
if (sub >= 0 && (best === null || sub > best)) best = sub;
}
return best;
}
/**
* Скоринг документа против нормализованного запроса.
* Пустой запрос — нейтральный балл; отсутствие совпадения — null.
*/
export function scoreDoc(
doc: SearchDoc,
normalizedQuery: string,
): number | null {
if (normalizedQuery.length === 0) return 1;
if (normalizeForSearch(doc.id).includes(normalizedQuery)) {
const at = normalizeForSearch(doc.id).indexOf(normalizedQuery);
const byId = 60 - Math.min(at, 40);
const byTitle = bestTitleScore(doc.titles, normalizedQuery);
if (byTitle !== null && byTitle >= 80) return byTitle;
return Math.max(byId, byTitle ?? -1);
}
const byTitle = bestTitleScore(doc.titles, normalizedQuery);
if (byTitle !== null && byTitle >= 20) return byTitle;
for (const raw of doc.descriptions) {
if (normalizeForSearch(raw).includes(normalizedQuery)) return 30;
}
return byTitle !== null && byTitle > 0 ? byTitle : null;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { isChainable, TOOLS } from "../registry";
import { normalizeForSearch, scoreDoc } from "./matching";
import { toolSearchDoc } from "./tool-strings";
function hits(q: string): string[] {
const nq = normalizeForSearch(q);
return TOOLS.filter((t) => {
const s = scoreDoc(toolSearchDoc(t), nq);
return s !== null && s > 0;
}).map((t) => t.id);
}
describe("полнота поиска", () => {
it("генератор (не chainable) находится поиском — раньше отфильтровывался", () => {
expect(isChainable(TOOLS.find((t) => t.id === "color-wheel-png")!)).toBe(
false,
);
expect(hits("color wheel")).toContain("color-wheel-png");
});
it("анализатор-маска тоже ищется", () => {
expect(hits("уникальных цветов")).toContain("unique-color-mask-png");
});
});
+96
View File
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { TOOLS } from "../registry";
import { LOCALE_TAGS } from "./dict";
import { setLocale } from "./locale.svelte";
import { normalizeForSearch, scoreDoc } from "./matching";
import { t } from "./t";
import { toolSearchDoc } from "./tool-strings";
afterEach(() => {
setLocale("ru");
vi.unstubAllGlobals();
});
describe("Смоук §6 i18n", () => {
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("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"],
];
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("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. Поиск кросс-языковой в обе стороны", () => {
const ids = (q: string) =>
TOOLS.filter((tool) => {
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q));
return s !== null && s > 0;
}).map((tool) => tool.id);
setLocale("ru");
let hits = ids("rotate");
expect(hits).toContain("rotate-png");
hits = ids("повер");
expect(hits).toContain("rotate-png");
setLocale("en");
hits = ids("пово");
expect(hits).toContain("rotate-free-png");
hits = ids("rotate");
expect(hits).toContain("rotate-png");
});
it("6b. Ё не мешает совпадению", () => {
const blackWhite = TOOLS.find((tool) => tool.id === "black-and-white-png")!;
setLocale("ru");
const hit = scoreDoc(
toolSearchDoc(blackWhite),
normalizeForSearch("ЧЁРНО"),
);
expect(hit).not.toBeNull();
const withoutYo = scoreDoc(
toolSearchDoc(blackWhite),
normalizeForSearch("черно"),
);
expect(withoutYo).not.toBeNull();
});
it("7. Теги локалей для форматирования чисел корректны", () => {
expect(LOCALE_TAGS.ru).toBe("ru-RU");
expect(LOCALE_TAGS.en).toBe("en-US");
});
});
+34
View File
@@ -0,0 +1,34 @@
import { getMergedDict } from "./locale.svelte";
export function interpolate(
template: string,
vars?: Record<string, string | number>,
): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in vars ? String(vars[name]) : match,
);
}
/**
* Перевод по точечному пути вида 'header.workspace' или 'errors.ERR_BAD_HEX'.
* Сначала активная локаль, затем базовая; если ключа нет нигде — возвращается сам путь.
*/
export function t(
path: string,
vars?: Record<string, string | number>,
): string {
let node: unknown = getMergedDict();
for (const part of path.split(".")) {
if (
node &&
typeof node === "object" &&
part in (node as Record<string, unknown>)
) {
node = (node as Record<string, unknown>)[part];
} else {
return path;
}
}
return typeof node === "string" ? interpolate(node, vars) : path;
}
+63
View File
@@ -0,0 +1,63 @@
import type { ParamDef, ToolEntry } from "$lib/v1/registry";
import { getMergedDict } from "./locale.svelte";
import type { SearchDoc } from "./matching";
import { ru } from "./ru";
/**
* Строки инструмента на активной локали.
* Русский текст живёт в самом реестре (базовая локаль),
* переводы — в секции tools словарей; отсутствие ключа даёт фолбэк на реестр.
*/
export function toolTitle(tool: ToolEntry): string {
return getMergedDict().tools[tool.id]?.title ?? tool.title;
}
export function toolDescription(tool: ToolEntry): string {
return getMergedDict().tools[tool.id]?.description ?? tool.description;
}
export function paramLabel(tool: ToolEntry, param: ParamDef): string {
const viaDict = getMergedDict().tools[tool.id]?.params?.[param.id];
if (viaDict) return viaDict;
const viaDef = param as { label?: string };
return viaDef.label ?? param.id;
}
export function optionLabel(
tool: ToolEntry,
param: ParamDef,
value: string,
): string {
const viaDict = getMergedDict().tools[tool.id]?.options?.[param.id]?.[value];
if (viaDict) return viaDict;
if (param.type === "select") {
return param.options.find((o) => o.value === value)?.label ?? value;
}
return value;
}
function dedupe(values: string[]): string[] {
return [...new Set(values.filter((v) => v.length > 0))];
}
/**
* Поисковый документ инструмента: строки активной локали, русского и английского
* (реестр) вместе — запрос находит инструмент по любому из языков независимо
* от того, какой сейчас включён.
*/
export function toolSearchDoc(tool: ToolEntry): SearchDoc {
const active = getMergedDict().tools[tool.id];
return {
id: tool.id,
titles: dedupe([
active?.title ?? "",
ru.tools[tool.id]?.title ?? "",
tool.title,
]),
descriptions: dedupe([
active?.description ?? "",
ru.tools[tool.id]?.description ?? "",
tool.description,
]),
};
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { TOOLS } from "../registry";
import { en } from "./en";
import { ru } from "./ru";
describe("секция tools словарей покрывает реестр", () => {
it("у каждого инструмента есть перевод ru с непустыми title/description", () => {
for (const tool of TOOLS) {
const strings = ru.tools[tool.id];
expect(strings, `нет перевода для ${tool.id}`).toBeDefined();
expect(strings?.title, `${tool.id}: title`).toBeTruthy();
expect(strings?.description, `${tool.id}: description`).toBeTruthy();
}
});
it("переведены подписи всех параметров", () => {
for (const tool of TOOLS) {
const params = ru.tools[tool.id]?.params ?? {};
for (const param of tool.params) {
const label = params[param.id];
expect(
label,
`${tool.id}.${param.id}: нет перевода подписи`,
).toBeDefined();
expect(label?.length).toBeGreaterThan(0);
}
}
});
it("переведены подписи всех опций select", () => {
for (const tool of TOOLS) {
const options = ru.tools[tool.id]?.options ?? {};
for (const param of tool.params) {
if (param.type !== "select") continue;
const labels = options[param.id] ?? {};
for (const option of param.options) {
const label = labels[option.value];
expect(
label,
`${tool.id}.${param.id}[${option.value}]: нет перевода`,
).toBeDefined();
expect(label.length).toBeGreaterThan(0);
}
}
}
});
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);
const tool = TOOLS.find((t) => t.id === id)!;
const paramIds = new Set(tool.params.map((p) => p.id));
for (const paramId of Object.keys(dict[id].params ?? {})) {
expect(
paramIds.has(paramId),
`${id}: лишний параметр ${paramId}`,
).toBe(true);
}
const selectIds = new Set(
tool.params.filter((p) => p.type === "select").map((p) => p.id),
);
for (const paramId of Object.keys(dict[id].options ?? {})) {
expect(
selectIds.has(paramId),
`${id}: options у не-select параметра ${paramId}`,
).toBe(true);
}
}
}
});
it("тексты-результаты анализаторов переведены в обоих словарях", () => {
const expectations: Array<[string, string[]]> = [
["png-is-grayscale", ["grayscaleYes", "grayscaleNo"]],
["png-is-transparent", ["transparentYes", "transparentNo"]],
[
"png-orientation",
["orientationPortrait", "orientationLandscape", "orientationSquare"],
],
];
for (const [id, keys] of expectations) {
for (const key of keys) {
expect(en.tools[id]?.results?.[key], `${id}.${key} (en)`).toBeDefined();
expect(ru.tools[id]?.results?.[key], `${id}.${key} (ru)`).toBeDefined();
}
}
});
});