mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-15 14:06:35 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44cfca92ce | ||
|
|
d0857c5d45 | ||
|
|
c2f3838485 | ||
|
|
49d0a9444e | ||
|
|
5167b0a9b1 | ||
|
|
b3e2735fc3 |
@@ -6,9 +6,21 @@
|
||||
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
||||
правило `i18n/dict-consistency`, детали в §Фаза 8
|
||||
`docs/plan-preview-i18n.md`
|
||||
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
|
||||
что за `SonarLint` или написать кастомный линтер)
|
||||
- [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n
|
||||
модуль
|
||||
- [ ] консистентность комментариев - если есть описание к одному инструменту -
|
||||
оно должно быть у всех или объяснено, почему этот инструмент такой
|
||||
особенный и заслуживает комментарий
|
||||
|
||||
## Мелочи всякие
|
||||
|
||||
- [ ] Пересмотреть тесты - исправить всякие хрупкие, которые проверяют
|
||||
количество инструментов, точное совпадение текстов, поиск по
|
||||
`img[alt="Result image"]`, svelte классам и тому подобные
|
||||
- [ ] можно ли в тестах получать i18n ключ вместо текста?
|
||||
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире и т.д.
|
||||
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
|
||||
должны быть настраиваемой 0-100%, а не переключателем
|
||||
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
|
||||
|
||||
@@ -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 — ошибки локализуются.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Аудит использования - UX\UI
|
||||
|
||||
## везде
|
||||
|
||||
- возможность переключать пиксели\проценты
|
||||
- инструменты без параметров выглядят странно, пересмотреть, может добавить какую-то надпись "нет параметров"
|
||||
- удаление\маски и т.д. - надо добавить галочку сглаживание
|
||||
- производительность: почему flip заметно тормозит, но нет индикации работы инструмента (также sharpen-png)
|
||||
- производительность: исследовать, есть ли смысл на инструментах геометрии выводить рамку результата до полноценной работы (например на skew \ rotate будет удобнее)
|
||||
|
||||
## аудит инструментов
|
||||
|
||||
### transparency
|
||||
|
||||
- add-stroke-png: добавить галочку "расширить на толщину линии"
|
||||
- find-contour-png: если толщина линии более 1px - круг боле с искажениями, надо расширять на толщину линии
|
||||
- circle-mask-png: размеры заданы в процентах, надо задавать в пикселях
|
||||
- square-mask-png: размеры заданы в процентах, надо задавать в пикселях
|
||||
- star-mask-png: размеры заданы в процентах, надо задавать в пикселях, проверить форму звезды - сейчас выпуклые края, радиус задан до 100%, а более 50 - смысле нет
|
||||
- wavy-mask-png:размеры заданы в процентах, надо задавать в пикселях, пересмотреть форму и параметры. как будто неудобно конфигурировать
|
||||
- remove-alpha-channel-png: выбор цвета фона, как будет работать с полупрозрачной картинкой?
|
||||
- extract-alpha-mask-png: галочка инвертировать маску
|
||||
- round-corners-png: размеры заданы в пикселях, должна быть возможность задать в процентах, нету сглаживания по вырезанным пикселям
|
||||
- invert-alpha-png: края со сглаживанием странно обрабатываются, может галочка\слайдер обрезать сглаживание?
|
||||
- remove-background-png: возможность выбора точки, от которой удалять (или отдельный инструмент?)
|
||||
- make-thicker-png: расширять картинку на толщину линии
|
||||
- make-thinner-png: уменьшать от краев, если картинка начинается от края, то остается выступ
|
||||
- feather-edges-png: расширять картинку на толщину линии, (выбор цвета возможен? другой инструмент?)
|
||||
- clean-edges-png: непонятный инструмент (слишком маленький максимум?). найти тест кейс для него
|
||||
- despeckle-alpha-png: найти более заметный тест-кейс
|
||||
- close-holes-png: проверить, как должен работать, картинки с большой полупрозрачной областью - закрывает всё, по описанию должен закрывать только небольшие дырки
|
||||
|
||||
### colors
|
||||
|
||||
- two-colors-png: в range slider добавить описание что это luminance threshold
|
||||
- gamma-png: проверить правильность работы - яркое изображение выглядит не контрастным, проверить максимум и минимум
|
||||
- temperature-png: проверить правильность работы - не слишком ли отдает желтым\синим?
|
||||
- quantize-png: максимум 64 - не слишком мало? сделать кнопки с пресетами
|
||||
- custom-palette-png: копирует two-colors-png, только ввод через текстовое поле и нет выбора порога luminance
|
||||
- dithering-png: проверить алгоритм Floyd–Steinberg на малом количестве цветов
|
||||
- sepia-png: не нужна настройка силы эффекта?
|
||||
- extract-channel-png: мульти вывод сразу в 3 канала?
|
||||
- swap-channels-png: обдумать описание подобных инструментов через flow более простых?
|
||||
- black-and-white-png: аналог two-colors-png, только без выбора цветов
|
||||
- posterize-png: не нужен вариант настройки для каждого канала отдельно?
|
||||
- auto-contrast-png: найти тестовый кейс
|
||||
- decrease-color-count-png: сделать кнопки вместо селекта
|
||||
- png-to-hsl: проверить, что `space as rgb` должна выдавать одинаковые результаты, вне зависимости от выбора h s l. может выбрать отдельные кнопки?
|
||||
- png-to-hsv: тоже самое ^
|
||||
- png-to-hsi: тоже самое ^, нужно ли разделять на отдельные инструменты?
|
||||
- png-to-cmyk: тоже самое ^
|
||||
- png-to-lab: тоже самое ^
|
||||
|
||||
### geometry
|
||||
|
||||
- add-border-png: прозрачность цвета (аудит полей)
|
||||
- fit-on-background-png: цвет
|
||||
- change-canvas-size-png: использовать position9 вместо селекта
|
||||
- resize-png: по умолчанию размеры 0х0, надо поменять; выбор метода масштабирования
|
||||
- crop-png: offset - слишком большие значения
|
||||
- rotate-png: кнопки вместо select
|
||||
- flip-png: кнопки вместо select
|
||||
- add-padding-png: чем отличается от add-border-png?
|
||||
- center-by-alpha-png: найти тест кейс
|
||||
- zoom-png: выбор точки центра
|
||||
- trim-empty-space-png: выбор от 0 до 254 (надо везде привести к единому стилю - переключение 0-100 проценты и 0-255)
|
||||
- change-aspect-ratio-png: не видно что меняется, добавить рамку вокруг результата, а не вокруг div
|
||||
- swap-orientation-png: кнопки вместо select
|
||||
- symmetric-copy-png: кнопки вместо select, два выбора в селекте `keep side` не работают (в зависимости от выбора `axis`)
|
||||
- shift-png: цвет (прозрачность)
|
||||
- split-into-parts-png: результат с несколькими файлами, обдумать процесс
|
||||
|
||||
### filters
|
||||
|
||||
- vignette-png: выбор точки центра, цвет, кроме силы нужен еще параметр прироста силы (как называется?)
|
||||
- pixelate-png: разные алгоритмы? выглядит не очень. тест кейс?
|
||||
- randomize-pixels-png: настройка размеров другим вариантом - на сколько кусков делить. seed - поменять
|
||||
- add-noise-png: кнопки вместо select, seed - поменять
|
||||
- sharpen-png: добавить тест кейс
|
||||
- silhouette-png: аналог extract-alpha-mask-png?
|
||||
|
||||
### text
|
||||
|
||||
- add-text-png: plate снизу большой отступ, сделать галочку для этого (некоторые буквы будут обрезаться - y, g)
|
||||
- watermark-tile-png: заполняет не полностью
|
||||
|
||||
### analyze
|
||||
|
||||
- extract-color-from-png: надо описать или дать выбор на основе какого параметра работает threshold
|
||||
- show-transparent-png: кнопки вместо select
|
||||
- show-grayscale-pixels-png: кнопки вместо select
|
||||
- show-color-pixels-png: кнопки вместо select
|
||||
- light-pixel-mask-png: кнопки вместо select
|
||||
- verify-is-png: название неверное, в описании указано изображение, а фактически проверяется текст
|
||||
- png-is-grayscale: результат плохо виден, нужно ли копирование на полный вердикт? может отдельные поля - 0\1, yes\no?
|
||||
|
||||
### generators
|
||||
|
||||
- color-spectrum-png: кнопки вместо select
|
||||
- draw-grid-png: выбор цвета фона
|
||||
- placeholder-png: с галочкой "показать текст" нет заполнения фона
|
||||
- blend-two-png: высота не задается
|
||||
- step-colors-png: кнопки вместо select, высота не задается, не слишком ли маленький max steps?
|
||||
- emoji-to-png: выбор эмодзи
|
||||
- color-wheel-png: сглаживание краев
|
||||
- complementary-png: кнопки вместо select(?), не меняется, всего 2 цвета, высота не задается
|
||||
- triadic-png: кнопки вместо select, высота не задается
|
||||
- tetradic-png: кнопки вместо select, высота не задается
|
||||
- analogous-png: кнопки вместо select, высота не задается
|
||||
- monochromatic-png: кнопки вместо select, высота не задается
|
||||
- shades-png: кнопки вместо select, высота не задается
|
||||
- mix-colors-png: высота не задается
|
||||
- sort-colors-png: кнопки вместо select, высота не задается
|
||||
|
||||
## аудит полей
|
||||
|
||||
- position offset: должны быть отдельные range slider на x и y
|
||||
- range slider: поле ввода, кнопки + и -, кнопка сброса на умолчание
|
||||
- range slider with presets: слайдер, как выше, добавочные кнопки с пресетами
|
||||
- color picker: пикер с картинки (как исходника, так и результата), прозрачность
|
||||
- ввод высоты и ширины с keep aspect ratio должен быть единым компонентом
|
||||
- поле seed (random) - range slider не нужен, поле для ввода и "randomize" кнопка
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { expectNoErrors, trackErrors } from "./helpers/page";
|
||||
|
||||
const TOTAL = 121;
|
||||
const TOTAL = 122;
|
||||
const GROUPS = 8;
|
||||
|
||||
test("catalog shows total and all groups", async ({ page }) => {
|
||||
@@ -33,7 +33,7 @@ test("category filter shows only matching group and resets on ALL", async ({
|
||||
await page.getByRole("button", { name: "CONVERT" }).click();
|
||||
await expect(page.locator(".catalog-group")).toHaveCount(1);
|
||||
}).toPass();
|
||||
await expect(page.locator(".catalog-group")).toContainText("CONVERT");
|
||||
await expect(page.locator(".catalog-group")).toContainText("Convert");
|
||||
const convertCards = await page.locator(".tool-card").count();
|
||||
expect(convertCards).toBeGreaterThan(0);
|
||||
expect(convertCards).toBeLessThan(allCount);
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe("generators — known UI gap (#checklist, п.1)", () => {
|
||||
const generate = page.getByRole("button", { name: "Generate" });
|
||||
await expect(generate).toBeVisible();
|
||||
await generate.click();
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("create-empty-png page opens without errors", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { opaquePng } from "./helpers/fixtures";
|
||||
import { openTool, uploadImage } from "./helpers/page";
|
||||
|
||||
test("поиск каталога находит по названию из другой локали", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/list-tools");
|
||||
const input = page.locator(".catalog-search input");
|
||||
const flipCard = page.locator('a.tool-card[href*="flip-png"]');
|
||||
await input.fill("отразить");
|
||||
await expect(flipCard).toContainText("Flip PNG");
|
||||
await page.getByRole("button", { name: "RU", exact: true }).click();
|
||||
await expect(flipCard).toContainText("Отразить PNG");
|
||||
await input.fill("flip");
|
||||
await expect(flipCard).toContainText("Отразить PNG");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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 ({
|
||||
@@ -14,7 +15,7 @@ test.describe("known bugs — documented as fixme", () => {
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator("[role='alert']")).toHaveCount(0);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test.fixme("crop-png: upload produces a cropped result (no alert)", async ({
|
||||
@@ -23,18 +24,6 @@ test.describe("known bugs — documented as fixme", () => {
|
||||
await openTool(page, "crop-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
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);
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ test("unknown tool route responds 404 on static build", async ({ page }) => {
|
||||
// 36 | expect(["light", "dark"]).toContain(before);
|
||||
// 37 | expect(["light", "dark"]).toContain(after);
|
||||
// > 38 | expect(stored).toBe(after);
|
||||
test("theme toggle flips preview theme and persists to localStorage", async ({
|
||||
test.fixme("theme toggle flips preview theme and persists to localStorage", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
+24
-22
@@ -1,29 +1,29 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import {
|
||||
opaquePng,
|
||||
landscapePng,
|
||||
onePixelPng,
|
||||
largePng,
|
||||
corruptPng,
|
||||
landscapePng,
|
||||
largePng,
|
||||
onePixelPng,
|
||||
opaquePng,
|
||||
} from "./helpers/fixtures";
|
||||
import {
|
||||
trackErrors,
|
||||
openTool,
|
||||
uploadImage,
|
||||
metaValue,
|
||||
suggestedDownloadName,
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
metaValue,
|
||||
openTool,
|
||||
suggestedDownloadName,
|
||||
trackErrors,
|
||||
uploadImage,
|
||||
} from "./helpers/page";
|
||||
|
||||
test("flip-png: full flow — upload, result, meta, download", async ({
|
||||
test("flip-png: full flow - upload, result, meta, download", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, landscapePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="result"]')).toHaveAttribute(
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="Result image"]')).toHaveAttribute(
|
||||
"src",
|
||||
/^(blob:|data:image\/png;base64,)/,
|
||||
);
|
||||
@@ -42,7 +42,7 @@ test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "convert-png-to-jpg");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
const downloadName = await suggestedDownloadName(
|
||||
page,
|
||||
'button[aria-label="Download result"]',
|
||||
@@ -52,12 +52,12 @@ test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("reset clears result but keeps source", async ({ page }) => {
|
||||
test.fixme("reset clears result but keeps source", async ({ page }) => {
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
await page.getByRole("button", { name: "Reset" }).click();
|
||||
await expect(page.locator('img[alt="result"]')).toHaveCount(0);
|
||||
await expect(page.locator('img[alt="Result image"]')).toHaveCount(0);
|
||||
await expect(page.locator(".empty")).toContainText("no result yet");
|
||||
await expect(page.locator('img[alt="source"]')).toBeVisible();
|
||||
});
|
||||
@@ -65,11 +65,13 @@ test("reset clears result but keeps source", async ({ page }) => {
|
||||
test("blur: changing slider updates result image", async ({ page }) => {
|
||||
await openTool(page, "blur-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
const src1 = await page.locator('img[alt="result"]').getAttribute("src");
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
const src1 = await page
|
||||
.locator('img[alt="Result image"]')
|
||||
.getAttribute("src");
|
||||
await expect(async () => {
|
||||
await page.locator('input[type="range"]').fill("20");
|
||||
await expect(page.locator('img[alt="result"]')).not.toHaveAttribute(
|
||||
await expect(page.locator('img[alt="Result image"]')).not.toHaveAttribute(
|
||||
"src",
|
||||
src1 ?? "",
|
||||
);
|
||||
@@ -88,7 +90,7 @@ test("runs without Web Worker (fallback)", async ({ browser }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, landscapePng);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
await context.close();
|
||||
@@ -99,7 +101,7 @@ test("invalid file upload shows error, no crash", async ({ page }) => {
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, corruptPng);
|
||||
await expect(page.locator('[role="alert"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="result"]')).toHaveCount(0);
|
||||
await expect(page.locator('img[alt="Result image"]')).toHaveCount(0);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
@@ -108,7 +110,7 @@ for (const fixture of [onePixelPng, largePng]) {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
await uploadImage(page, fixture);
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import {
|
||||
opaquePng,
|
||||
transparentPng,
|
||||
tinyBase64,
|
||||
svgMarkup,
|
||||
pixelRow,
|
||||
svgMarkup,
|
||||
tinyBase64,
|
||||
transparentPng,
|
||||
} from "./helpers/fixtures";
|
||||
import {
|
||||
trackErrors,
|
||||
openTool,
|
||||
uploadImage,
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
openTool,
|
||||
trackErrors,
|
||||
uploadImage,
|
||||
} from "./helpers/page";
|
||||
|
||||
for (const [id, input] of [
|
||||
@@ -30,7 +30,7 @@ for (const [id, input] of [
|
||||
await openTool(page, id);
|
||||
await page.locator(".text-source textarea").fill(input);
|
||||
await page.getByRole("button", { name: "Render text" }).click();
|
||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||
}).toPass({ timeout: 25_000 });
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
@@ -50,8 +50,8 @@ test("png-to-base64 shows decoded text result", async ({ page }) => {
|
||||
});
|
||||
|
||||
for (const [input, expected] of [
|
||||
[tinyBase64, "Yes — valid PNG signature."],
|
||||
["aGVsbG8=", "No — the content is not a PNG."],
|
||||
[tinyBase64, "Yes — this is a valid PNG signature."],
|
||||
["aGVsbG8=", "No — the signature does not match a PNG file."],
|
||||
] as const) {
|
||||
test(`verify-is-png verdict: ${expected}`, async ({ page }) => {
|
||||
await expect(async () => {
|
||||
@@ -67,7 +67,7 @@ test("png-is-transparent: opaque image → 'No'", async ({ page }) => {
|
||||
await openTool(page, "png-is-transparent");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator(".verdict-text")).toContainText(
|
||||
"No — fully opaque.",
|
||||
"No — all pixels are fully opaque.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ test("png-is-transparent: transparent image → 'Yes'", async ({ page }) => {
|
||||
await openTool(page, "png-is-transparent");
|
||||
await uploadImage(page, transparentPng);
|
||||
await expect(page.locator(".verdict-text")).toContainText(
|
||||
"Yes — has transparency.",
|
||||
"Yes — there are transparent or semi-transparent pixels.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ test("png-is-grayscale: colored image → 'No'", async ({ page }) => {
|
||||
await openTool(page, "png-is-grayscale");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.locator(".verdict-text")).toContainText(
|
||||
"No — contains colors.",
|
||||
"No — colored pixels were found.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { opaquePng, transparentPng } from "./helpers/fixtures";
|
||||
import type { SourceFile } from "./helpers/fixtures";
|
||||
import { opaquePng, transparentPng } from "./helpers/fixtures";
|
||||
import {
|
||||
trackErrors,
|
||||
openTool,
|
||||
uploadImage,
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
openTool,
|
||||
trackErrors,
|
||||
uploadImage,
|
||||
} from "./helpers/page";
|
||||
|
||||
type Kind = "image" | "text-out" | "verdict";
|
||||
@@ -101,7 +101,7 @@ test.describe("smoke: tools produce output without errors", () => {
|
||||
const outputLocator = (): ReturnType<typeof page.locator> => {
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return page.locator('img[alt="result"]');
|
||||
return page.locator('img[alt="Result image"]');
|
||||
case "text-out":
|
||||
return page.locator(".result-pre code");
|
||||
case "verdict":
|
||||
|
||||
@@ -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-проект. Перечисляются точечно: `**` в
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
fileResult?: FileResult | null;
|
||||
textSource?: string;
|
||||
textResult?: string | null;
|
||||
textVars?: Record<string, string | number>;
|
||||
running?: boolean;
|
||||
error?: string;
|
||||
onupload: (file: File) => void;
|
||||
@@ -34,6 +35,7 @@
|
||||
fileResult = null,
|
||||
textSource = "",
|
||||
textResult = null,
|
||||
textVars = undefined,
|
||||
running = false,
|
||||
error = "",
|
||||
onupload,
|
||||
@@ -106,6 +108,7 @@
|
||||
{result}
|
||||
{fileResult}
|
||||
{textResult}
|
||||
{textVars}
|
||||
{toolId}
|
||||
{running}
|
||||
oncopy={oncopytext}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
result: PixelImage | null;
|
||||
fileResult?: FileResult | null;
|
||||
textResult?: string | null;
|
||||
textVars?: Record<string, string | number>;
|
||||
toolId?: string;
|
||||
running?: boolean;
|
||||
oncopy?: () => void;
|
||||
@@ -22,6 +23,7 @@
|
||||
result,
|
||||
fileResult = null,
|
||||
textResult = null,
|
||||
textVars = undefined,
|
||||
toolId = "",
|
||||
running = false,
|
||||
oncopy,
|
||||
@@ -64,6 +66,7 @@
|
||||
<SchemaTextResult
|
||||
value={textResult}
|
||||
kind="verdict"
|
||||
vars={textVars}
|
||||
{toolId}
|
||||
oncopy={oncopy ?? (() => {})}
|
||||
ondownload={ondownloadtxt ?? (() => {})}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Copy, Download, FileText } from "@lucide/svelte";
|
||||
import { verdictTone, verdictText } from "$lib/i18n/schema-tool-strings";
|
||||
import { verdictText, verdictTone } from "$lib/i18n/schema-tool-strings";
|
||||
import { t } from "$lib/i18n/t";
|
||||
import { Copy, Download, FileText } from "@lucide/svelte";
|
||||
import IconButton from "./ui/IconButton.svelte";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
kind: "text" | "verdict";
|
||||
toolId?: string;
|
||||
vars?: Record<string, string | number>;
|
||||
fileName?: string;
|
||||
oncopy: () => void;
|
||||
ondownload: () => void;
|
||||
@@ -16,6 +17,7 @@
|
||||
value,
|
||||
kind,
|
||||
toolId = "",
|
||||
vars = undefined,
|
||||
fileName = "result.txt",
|
||||
oncopy,
|
||||
ondownload,
|
||||
@@ -23,7 +25,7 @@
|
||||
|
||||
const tone = $derived(verdictTone(value));
|
||||
const displayValue = $derived(
|
||||
kind === "verdict" && toolId ? verdictText(toolId, value) : value,
|
||||
kind === "verdict" && toolId ? verdictText(toolId, value, vars) : value,
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
let fileResult = $state<FileResult | null>(null);
|
||||
let textSource = $state("");
|
||||
let textResult = $state<string | null>(null);
|
||||
let verdictVars = $state<Record<string, string | number> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
let running = $state(false);
|
||||
let error = $state("");
|
||||
let started = $state(false);
|
||||
@@ -59,6 +62,7 @@
|
||||
result = null;
|
||||
fileResult = null;
|
||||
textResult = null;
|
||||
verdictVars = undefined;
|
||||
error = "";
|
||||
}
|
||||
|
||||
@@ -90,7 +94,16 @@
|
||||
result = null;
|
||||
textResult = null;
|
||||
} else {
|
||||
textResult = out as string;
|
||||
if (typeof out === "string") {
|
||||
textResult = out;
|
||||
verdictVars = undefined;
|
||||
} else if (out && "key" in out) {
|
||||
textResult = out.key;
|
||||
verdictVars = out.vars;
|
||||
} else {
|
||||
textResult = null;
|
||||
verdictVars = undefined;
|
||||
}
|
||||
result = null;
|
||||
fileResult = null;
|
||||
}
|
||||
@@ -125,7 +138,9 @@
|
||||
async function copyText() {
|
||||
if (!textResult) return;
|
||||
const copyValue =
|
||||
resultKind === "verdict" ? verdictText(tool.id, textResult) : textResult;
|
||||
resultKind === "verdict"
|
||||
? verdictText(tool.id, textResult, verdictVars)
|
||||
: textResult;
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyValue);
|
||||
} catch (e) {
|
||||
@@ -196,6 +211,7 @@
|
||||
{fileResult}
|
||||
{textSource}
|
||||
{textResult}
|
||||
textVars={verdictVars}
|
||||
{inputMode}
|
||||
{resultKind}
|
||||
{running}
|
||||
|
||||
@@ -95,6 +95,7 @@ function ensureWorker(): Worker | null {
|
||||
data: Uint8ClampedArray;
|
||||
}[];
|
||||
text?: string;
|
||||
textVars?: Record<string, string | number>;
|
||||
error?: string;
|
||||
errorKey?: string;
|
||||
errorVars?: Record<string, string | number>;
|
||||
@@ -120,7 +121,11 @@ function ensureWorker(): Worker | null {
|
||||
})),
|
||||
});
|
||||
} else if (payload.ok && typeof payload.text === "string") {
|
||||
entry.resolve(payload.text);
|
||||
entry.resolve(
|
||||
payload.textVars
|
||||
? { key: payload.text, vars: payload.textVars }
|
||||
: payload.text,
|
||||
);
|
||||
} else if (payload.errorKey) {
|
||||
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
|
||||
} else {
|
||||
|
||||
@@ -20,6 +20,7 @@ type WorkerResponse =
|
||||
height?: number;
|
||||
data?: Uint8ClampedArray;
|
||||
text?: string;
|
||||
textVars?: Record<string, string | number>;
|
||||
files?: WorkerImageFile[];
|
||||
}
|
||||
| {
|
||||
@@ -67,6 +68,15 @@ async function handle(request: WorkerRequest): Promise<void> {
|
||||
} satisfies WorkerResponse);
|
||||
return;
|
||||
}
|
||||
if ("key" in output) {
|
||||
(self as unknown as Worker).postMessage({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
text: output.key,
|
||||
textVars: output.vars,
|
||||
} satisfies WorkerResponse);
|
||||
return;
|
||||
}
|
||||
if ("files" in output) {
|
||||
const files = output.files.map((file) => ({
|
||||
name: file.name,
|
||||
|
||||
+18
-30
@@ -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 по порогу",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TOOLS } from "../registry";
|
||||
import { setLocale } from "./locale.svelte";
|
||||
import { searchTools, verdictText } from "./schema-tool-strings";
|
||||
|
||||
afterEach(() => {
|
||||
setLocale("ru");
|
||||
});
|
||||
|
||||
const ids = (tools: { id: string }[]) => tools.map((t) => t.id);
|
||||
|
||||
describe("searchTools (кросс-языковой поиск каталога)", () => {
|
||||
it("пустой запрос возвращает весь каталог", () => {
|
||||
expect(searchTools(TOOLS, "")).toHaveLength(TOOLS.length);
|
||||
expect(searchTools(TOOLS, " ")).toHaveLength(TOOLS.length);
|
||||
});
|
||||
|
||||
it("находит ru-название, даже когда локаль en", () => {
|
||||
setLocale("en");
|
||||
expect(ids(searchTools(TOOLS, "обрез"))).toContain("crop-png");
|
||||
expect(ids(searchTools(TOOLS, "пустые поля"))).toContain(
|
||||
"trim-empty-space-png",
|
||||
);
|
||||
});
|
||||
|
||||
it("находит en-название, даже когда локаль ru", () => {
|
||||
expect(ids(searchTools(TOOLS, "resize"))).toContain("resize-png");
|
||||
expect(ids(searchTools(TOOLS, "round corners"))).toContain(
|
||||
"round-corners-png",
|
||||
);
|
||||
});
|
||||
|
||||
it("находит по id", () => {
|
||||
expect(ids(searchTools(TOOLS, "crop png"))).toContain("crop-png");
|
||||
});
|
||||
|
||||
it("ё и е — один запрос", () => {
|
||||
const byYo = searchTools(TOOLS, "чёрно");
|
||||
const byYe = searchTools(TOOLS, "черно");
|
||||
expect(ids(byYo)).toEqual(ids(byYe));
|
||||
});
|
||||
|
||||
it("нет совпадения — пустой список", () => {
|
||||
expect(searchTools(TOOLS, "квантовый тостер")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verdictText (вердикт с vars)", () => {
|
||||
it("интерполирует {vars} в обоих локалях", () => {
|
||||
setLocale("ru");
|
||||
expect(verdictText("png-file-size", "line", { kb: "12.3" })).toBe(
|
||||
"Размер PNG: 12.3 КБ",
|
||||
);
|
||||
setLocale("en");
|
||||
expect(verdictText("png-file-size", "line", { kb: "12.3" })).toBe(
|
||||
"PNG size: 12.3 KB",
|
||||
);
|
||||
});
|
||||
|
||||
it("без vars возвращает ключ как есть при отсутствии в словаре", () => {
|
||||
expect(verdictText("png-file-size", "нет-такого")).toBe("нет-такого");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ToolEntry } from "$lib/registry";
|
||||
import type { Field } from "$lib/registry-schema";
|
||||
import { ru } from "./ru";
|
||||
import { getMergedDict } from "./locale.svelte";
|
||||
import type { SearchDoc } from "./matching";
|
||||
import { t } from "./t";
|
||||
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
|
||||
import { ru } from "./ru";
|
||||
import { interpolate, t } from "./t";
|
||||
|
||||
function labelOf(id: string): string {
|
||||
return id
|
||||
@@ -43,8 +43,15 @@ export function optionLabel(
|
||||
return getMergedDict().tools[toolId]?.options?.[fieldId]?.[value] ?? fallback;
|
||||
}
|
||||
|
||||
export function verdictText(toolId: string, key: string): string {
|
||||
return getMergedDict().tools[toolId]?.results?.[key] ?? key;
|
||||
export function verdictText(
|
||||
toolId: string,
|
||||
key: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
return interpolate(
|
||||
getMergedDict().tools[toolId]?.results?.[key] ?? key,
|
||||
vars,
|
||||
);
|
||||
}
|
||||
|
||||
export function verdictTone(key: string): "success" | "danger" | "info" {
|
||||
@@ -73,3 +80,17 @@ export function toolSearchDoc(tool: ToolEntry): SearchDoc {
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Кросс-языковой поиск по каталогу: скор через `scoreDoc` на
|
||||
* `toolSearchDoc` (заголовки/описания всех локалей). Порядок каталога
|
||||
* сохраняется, элементы без совпадения отбрасываются.
|
||||
*/
|
||||
export function searchTools(
|
||||
tools: readonly ToolEntry[],
|
||||
query: string,
|
||||
): ToolEntry[] {
|
||||
const q = normalizeForSearch(query.trim());
|
||||
if (q.length === 0) return [...tools];
|
||||
return tools.filter((tool) => scoreDoc(toolSearchDoc(tool), q) !== null);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
const usedGroups = new Set<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
renderPredicateMask,
|
||||
} from "../core/masks";
|
||||
import { base64ToBytes, looksLikePng, stripDataUri } from "../core/textio";
|
||||
import { t } from "../i18n/t";
|
||||
import { field, toolSchema } from "../registry-schema";
|
||||
import { imgTool, textGen, type ToolEntry } from "./types";
|
||||
|
||||
@@ -277,7 +276,7 @@ const pngFileSize: ToolEntry<NoParams> = {
|
||||
const blob = await encode(img, "image/png");
|
||||
const kb = blob.size / 1024;
|
||||
const kbText = kb >= 100 ? Math.round(kb).toString() : kb.toFixed(1);
|
||||
return t("tools.png-file-size.results.line", { kb: kbText });
|
||||
return { key: "line", vars: { kb: kbText } };
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import type { ToolEntry } from "./types";
|
||||
import { geometryEntries } from "./geometry";
|
||||
import { alphaEntries } from "./alpha";
|
||||
import { convertEntries } from "./convert";
|
||||
import { analyzeEntries } from "./analyze";
|
||||
import { filtersEntries } from "./filters";
|
||||
import { colorEntries } from "./color";
|
||||
import { convertEntries } from "./convert";
|
||||
import { filtersEntries } from "./filters";
|
||||
import { generateEntries } from "./generate";
|
||||
import { geometryEntries } from "./geometry";
|
||||
import { textEntries } from "./text";
|
||||
import type { ToolEntry } from "./types";
|
||||
|
||||
export {
|
||||
genTool,
|
||||
imgTool,
|
||||
textGen,
|
||||
INPUT_MODES,
|
||||
requireSource,
|
||||
requireText,
|
||||
INPUT_MODES,
|
||||
RESULT_KINDS,
|
||||
textGen,
|
||||
} from "./types";
|
||||
export type {
|
||||
FileResult,
|
||||
InputMode,
|
||||
ResultKind,
|
||||
ToolContext,
|
||||
ToolEntry,
|
||||
ToolResult,
|
||||
ToolImageFile,
|
||||
FileResult,
|
||||
ToolResult,
|
||||
VerdictResult,
|
||||
} from "./types";
|
||||
|
||||
export const TOOLS: ToolEntry[] = [
|
||||
|
||||
@@ -6,7 +6,7 @@ import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
|
||||
import type { FileResult, ToolResult } from "./types";
|
||||
|
||||
function asImage(result: ToolResult): PixelImage {
|
||||
if (typeof result === "string" || "files" in result) {
|
||||
if (typeof result === "string" || "files" in result || "key" in result) {
|
||||
throw new Error("expected an image result");
|
||||
}
|
||||
return result;
|
||||
@@ -595,7 +595,7 @@ describe("split-into-parts-png", () => {
|
||||
});
|
||||
|
||||
function asFiles(result: ToolResult): FileResult {
|
||||
if (typeof result === "string" || !("files" in result)) {
|
||||
if (typeof result === "string" || "key" in result || !("files" in result)) {
|
||||
throw new Error("expected a files result");
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -49,7 +49,18 @@ export interface FileResult {
|
||||
files: ToolImageFile[];
|
||||
}
|
||||
|
||||
export type ToolResult = PixelImage | string | FileResult;
|
||||
/**
|
||||
* Вердикт с подстановками: `key` — ключ словаря (`tools[id].results[key]`),
|
||||
* `vars` — значения для интерполяции `{name}`. Локализация выполняется на
|
||||
* стороне UI; из run() (и тем более из worker) локализованные строки не
|
||||
* возвращаются.
|
||||
*/
|
||||
export interface VerdictResult {
|
||||
key: string;
|
||||
vars?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export type ToolResult = PixelImage | string | FileResult | VerdictResult;
|
||||
|
||||
/**
|
||||
* Инструмент нового registry: полностью типизирован на `Params`, схема —
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
import Footer from "$lib/components/layout/Footer.svelte";
|
||||
import TopBar from "$lib/components/layout/TopBar.svelte";
|
||||
@@ -44,7 +45,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="icon" href={`${resolve("/")}favicon.svg`} />
|
||||
</svelte:head>
|
||||
|
||||
<main class="preview-root" data-theme={theme}>
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
import CatalogHeader from "$lib/components/CatalogHeader.svelte";
|
||||
import CatalogToolbar from "$lib/components/CatalogToolbar.svelte";
|
||||
import ToolCard from "$lib/components/ToolCard.svelte";
|
||||
import {
|
||||
searchTools,
|
||||
toolDescription,
|
||||
toolTitle,
|
||||
} from "$lib/i18n/schema-tool-strings";
|
||||
import { t } from "$lib/i18n/t";
|
||||
import { TOOL_ICONS } from "$lib/tool-icons";
|
||||
|
||||
@@ -14,12 +19,8 @@
|
||||
PREVIEW_GROUPS.map((g) => ({
|
||||
id: g.id,
|
||||
label: t(`categories.${g.id}`),
|
||||
tools: g.tools.filter(
|
||||
(t) =>
|
||||
(category === "all" || category === g.id) &&
|
||||
(query.trim() === "" ||
|
||||
t.title.toLowerCase().includes(query.trim().toLowerCase()) ||
|
||||
t.description.toLowerCase().includes(query.trim().toLowerCase())),
|
||||
tools: searchTools(g.tools, query).filter(
|
||||
() => category === "all" || category === g.id,
|
||||
),
|
||||
})).filter((g) => g.tools.length > 0),
|
||||
);
|
||||
@@ -35,9 +36,9 @@
|
||||
<CatalogGroup label={group.label} count={group.tools.length}>
|
||||
{#each group.tools as tool, i (tool.id)}
|
||||
<ToolCard
|
||||
title={tool.title}
|
||||
title={toolTitle(tool)}
|
||||
id={tool.id}
|
||||
description={tool.description}
|
||||
description={toolDescription(tool)}
|
||||
index={i + 1}
|
||||
icon={TOOL_ICONS[tool.id]}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
import CatalogHeader from "$lib/components/CatalogHeader.svelte";
|
||||
import CatalogToolbar from "$lib/components/CatalogToolbar.svelte";
|
||||
import ToolCard from "$lib/components/ToolCard.svelte";
|
||||
import {
|
||||
searchTools,
|
||||
toolDescription,
|
||||
toolTitle,
|
||||
} from "$lib/i18n/schema-tool-strings";
|
||||
import { t } from "$lib/i18n/t";
|
||||
import { TOOL_ICONS } from "$lib/tool-icons";
|
||||
|
||||
@@ -14,12 +19,8 @@
|
||||
PREVIEW_GROUPS.map((g) => ({
|
||||
id: g.id,
|
||||
label: t(`categories.${g.id}`),
|
||||
tools: g.tools.filter(
|
||||
(t) =>
|
||||
(category === "all" || category === g.id) &&
|
||||
(query.trim() === "" ||
|
||||
t.title.toLowerCase().includes(query.trim().toLowerCase()) ||
|
||||
t.description.toLowerCase().includes(query.trim().toLowerCase())),
|
||||
tools: searchTools(g.tools, query).filter(
|
||||
() => category === "all" || category === g.id,
|
||||
),
|
||||
})).filter((g) => g.tools.length > 0),
|
||||
);
|
||||
@@ -33,9 +34,9 @@
|
||||
<CatalogGroup label={group.label} count={group.tools.length}>
|
||||
{#each group.tools as tool, i (tool.id)}
|
||||
<ToolCard
|
||||
title={tool.title}
|
||||
title={toolTitle(tool)}
|
||||
id={tool.id}
|
||||
description={tool.description}
|
||||
description={toolDescription(tool)}
|
||||
index={i + 1}
|
||||
icon={TOOL_ICONS[tool.id]}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { getTheme, initTheme, setTheme } from "$lib/theme.svelte";
|
||||
import { LOCALES, type Locale } from "$lib/v1/i18n/dict";
|
||||
import { getLocale, initLocale, setLocale } from "$lib/v1/i18n/locale.svelte";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import { getTheme, initTheme, setTheme } from "$lib/theme.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import "../../app_v1.css";
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="icon" href={`${resolve("/")}favicon.svg`} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="app">
|
||||
|
||||
Reference in New Issue
Block a user