diff --git a/docs/README.md b/docs/README.md index 67c5700..f5d1a7d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,17 +51,17 @@ только если темы нет в существующем документе; иначе обновляется источник истины. -## Текущий baseline Q4 +## Текущий baseline Q5 -На момент фиксации Q4: +На момент фиксации Q5: - production registry содержит 122 инструмента; - `pnpm --dir web check` проходит без ошибок и предупреждений; -- `pnpm --dir web test` — 39 test files / 425 tests; +- `pnpm --dir web test` — 39 test files / 431 tests; - `pnpm --dir web test:rules` — 4 test files / 74 tests; - `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused design tokens от token audit; -- `pnpm verify` проходит; production E2E — 105 passed, 4 skipped. +- `pnpm verify` проходит; production E2E — 109 passed, 0 skipped. Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`. diff --git a/docs/backlog.md b/docs/backlog.md index 7f42ed4..5e59337 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -105,37 +105,17 @@ - [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier (после wasm-фаз 5–7) -## Баги preview +## Качество production UI -Зарегистрированы при написании e2e (`web/e2e/`, `pnpm --dir web test:e2e`) и -ручного чек-листа — см. `docs/checklist-manual-testing.md` (раздел G). Каждый -пункт связан с соответствующим `test.fixme` или отдельным тестовым долгом. После -фикса: убрать `test.fixme` и отметить пункт G в чек-листе как пройденный. +Q5 закрывает известные preview-проблемы; активных `test.fixme` в e2e больше нет. -1. **Генераторы без автоматического результата** — все 21 инструмент категории - GENERATE открываются, но в UI нет полей схемы и результата; доступны только - RU/EN/Reset. В `SchemaToolView.svelte` уже есть auto-run через debounce, - поэтому отдельная кнопка Generate не требуется. Ожидание: поля схемы, - автоматический запуск по дефолтам и результат-картинка. Fixme: - `web/e2e/generators.spec.ts`. Кнопка Generate не добавляется без отдельного - UX-решения. -2. **resize-png с дефолтами падает** — схема по умолчанию `size: 0×0` + - `keepAspect: true` → при загрузке ЛЮБОГО PNG сразу `errors.resizeSize` - (alert), результата нет. `web/src/lib/registry/geometry.ts`. Ожидание: - осмысленный дефолт-размер (например, исходный) либо отдельный режим ввода, а - не авто-ошибка. -3. **crop-png с дефолтами падает** — аналогично: `width: 0, height: 0` → сразу - `errors.cropSize`. `web/src/lib/registry/geometry.ts`. Ожидание: базовый кроп - по умолчанию либо внятное предзаполнение. -4. **Сырые ключи ошибок в UI** — alert на tool-странице показывает сырой ключ - i18n (`errors.resizeSize`), а не сообщение из `en.ts` / `ru.ts` (там - `resizeSize: "Width and/or height must be positive"`). Проверяется на - resize-png / crop-png. Ожидание: человекочитаемое сообщение на текущем языке. - Fixme: `web/e2e/known-issues.spec.ts` (тест «error message localized»). -5. **Reset и auto-run требуют решения** — `pipeline.spec.ts:55-63` ожидает - очистку результата, но текущий `SchemaToolView.svelte:162-173` может сразу - перезапустить обработку. Нужно определить семантику Reset, затем исправить - тест и снять `test.fixme`. +- [x] Генераторы auto-run без кнопки Generate — `web/e2e/generators.spec.ts`. +- [x] `resize-png` и `crop-png` используют размеры текущего source через общий + source-aware schema resolver — `web/e2e/pipeline.spec.ts`. +- [x] Reset сохраняет source/text, восстанавливает defaults и запускает deferred + auto-run — `web/e2e/pipeline.spec.ts`. +- [x] Ошибки хранят i18n key/vars и переводятся при выводе; изменение языка + обновляет alert — `web/e2e/i18n.spec.ts`. ## Идеи diff --git a/docs/checklist-manual-testing.md b/docs/checklist-manual-testing.md index 3b8895e..ca3e065 100644 --- a/docs/checklist-manual-testing.md +++ b/docs/checklist-manual-testing.md @@ -95,24 +95,19 @@ корректный (не «кракозябры»). - [ ] remove-background на сложной полупрозрачности — края не «звенят». -## G. Известные баги preview +## G. Закрытые Q5-проблемы -> Список синхронизирован с `docs/backlog.md`. Каждый пункт связан с -> зарегистрированной проблемой или соответствующим `test.fixme`; после -> исправления отметить его здесь и убрать `test.fixme` в том же изменении. +> Q5-проблемы закрыты автоматическими тестами; для ручной проверки остаются +> субъективные сценарии разделов A–F. -- [ ] **Генераторы:** открыть инструмент категории GENERATE, проверить поля - схемы, автоматический запуск по дефолтам и появление результата; кнопка - Generate не требуется. `docs/backlog.md`, `web/e2e/generators.spec.ts`. -- [ ] **resize-png:** загрузить изображение и получить результат без alert. - `docs/backlog.md`, `web/e2e/known-issues.spec.ts`. -- [ ] **crop-png:** загрузить изображение и получить осмысленный результат без - alert. `docs/backlog.md`, `web/e2e/known-issues.spec.ts`. -- [ ] **Локализация ошибок:** получить человекочитаемое сообщение на текущем - языке вместо сырого i18n-ключа. `docs/backlog.md`, `web/e2e/i18n.spec.ts`. -- [ ] **Reset/auto-run:** проверить выбранную семантику Reset и автоматический - перезапуск после изменения параметров. `docs/backlog.md`, - `web/e2e/pipeline.spec.ts`. +- [x] **Генераторы:** auto-run по дефолтам, результат и изменение параметра без + кнопки Generate. `web/e2e/generators.spec.ts`. +- [x] **resize-png/crop-png:** размеры текущего source используются по + умолчанию, alert не появляется. `web/e2e/pipeline.spec.ts`. +- [x] **Reset:** defaults восстанавливаются, source сохраняется, deferred + auto-run завершается результатом. `web/e2e/pipeline.spec.ts`. +- [x] **Локализация ошибок:** alert хранит key/vars, переводится при выводе и + обновляется при смене языка. `web/e2e/i18n.spec.ts`. ## Как долго diff --git a/docs/plan-codebase-quality.md b/docs/plan-codebase-quality.md index 9011b66..ec5e934 100644 --- a/docs/plan-codebase-quality.md +++ b/docs/plan-codebase-quality.md @@ -1,9 +1,9 @@ # План: линтеры, тесты и качество кодовой базы -> Статус: **draft — Q0–Q4 готовы к ревью; Q5 — следующий этап**. +> Статус: **draft — Q0–Q5 готовы к ревью; Q6 — следующий этап**. > > Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и -> инфраструктурных скриптов. Q0–Q4 выполнены; Q5–Q8 выполняются отдельными +> инфраструктурных скриптов. Q0–Q5 выполнены; Q6–Q8 выполняются отдельными > атомарными шагами. > > Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность» @@ -45,14 +45,10 @@ docs и production build; deploy выполняет его перед сборкой. - `i18n/dict-consistency` даёт 0 warnings на текущем baseline; отдельный debt — 4 unused design tokens от token audit. -- В e2e остаются фиксированные числа, точные тексты, CSS-классы и - `alt`-селекторы: `web/e2e/catalog.spec.ts:4-55`, - `web/e2e/text-and-verdicts.spec.ts:52-101`. -- Backlog и реализация расходятся по генераторам: продуктовая заметка требует - автоматический запуск без `Generate`-кнопки (`docs/backlog.md:115-122`), а - тест ожидает кнопку (`web/e2e/generators.spec.ts:8-16`). -- `resize-png` и `crop-png` начинают с нулевых размеров и требуют отдельного - продуктового решения (`web/src/lib/registry/geometry.ts:191-297`). +- E2E-контракт использует semantic locators и общий source-aware schema + resolver; активных known-issue `test.fixme` нет. +- `ToolError` хранится как key/vars и переводится только при выводе; смена + локали обновляет уже видимый alert. ## 3. Этапы выполнения @@ -175,27 +171,29 @@ pnpm --dir web test:e2e **Результат:** изменение формулировки, класса или порядка элементов не ломает поведенческий тест без изменения пользовательского контракта; следующий этап — -Q5. +Q6. ### Q5. Закрыть известные preview-баги -**Оценка:** M. **Зависимости:** Q4. +**Статус:** выполнен. **Проверено:** unit, Q5 E2E smoke и полный `pnpm verify` +проходят; активных known-issue `test.fixme` нет. **Оценка:** M–L. +**Зависимости:** Q4. -1. Выбрать поведение `resize-png` и `crop-png` при загрузке изображения: - source-aware defaults, осмысленные статические defaults или отдельный - автоматический repair. -2. Исправить registry/schema и только после этого снять соответствующие - `test.fixme`. -3. Зафиксировать auto-run для генераторов и переписать e2e под фактическое - автоматическое поведение; кнопку Generate не добавлять без отдельного UX - решения. -4. Определить семантику Reset: очистка результата, немедленный повторный run или - временное отключение auto-run. -5. Перенести проверку локализованной ошибки на независимый детерминированный - invalid-input сценарий. +- Добавлен общий opt-in `defaultFromSource` resolver в `registry-schema`: он + принимает source dimensions, обновляет только source-aware поля при upload и + используется в UI, executor и worker. `resize-png` и `crop-png` получают + размеры текущего source; static/generator/text schemas не затронуты. +- Reset сохраняет source/text, восстанавливает source-aware defaults и запускает + deferred auto-run через debounce. +- Генераторы проверены в auto-run без кнопки Generate; кнопка не добавляется. +- `ToolError` хранится как key/vars, переводится derived-значением при выводе и + обновляется при смене локали. E2E использует независимый invalid pixel-count + сценарий. +- Resize/crop, generator, Reset и localization fixme сняты; Q6 начинает + расширение покрытия, а не исправление этих контрактов. -**Готово, когда:** каждый known issue либо исправлен и покрыт зелёным тестом, -либо явно перенесён в отдельную продуктовую задачу с владельцем и критерием. +**Результат:** Q5 known issues закрыты и отражены в `docs/backlog.md` и +`docs/checklist-manual-testing.md`; следующий этап — Q6. ### Q6. Расширение unit и browser-покрытия @@ -373,9 +371,9 @@ refactor, новые i18n-правила и исправление продук ## 7. Решения, которые нужно принять на ревью -- Какой default выбрать для `resize-png` и `crop-png` при загрузке? -- Подтверждаем ли auto-run генераторов без кнопки Generate? -- Что именно должен делать Reset при включённом auto-run? -- Какой независимый invalid-input сценарий заменит resize-dependent error test? +- Какие executor/worker/fallback сценарии войдут в первый Q6 coverage PR? +- Какие special PNG fixtures обязательны для baseline? +- Нужны ли browser/mobile matrix и visual snapshots до coverage thresholds? +- Какие unused design tokens можно удалить, а какие должны остаться? - Какие browser/e2e проверки должны быть обязательными для PR, а какие — отдельным ночным прогоном? diff --git a/docs/plan-fields-audit-fixes.md b/docs/plan-fields-audit-fixes.md index cb118cc..c26a30f 100644 --- a/docs/plan-fields-audit-fixes.md +++ b/docs/plan-fields-audit-fixes.md @@ -181,11 +181,12 @@ buttons: (s: Omit, "kind">): Field => ({ ## 3. Волна C — обязательные фиксы (баги + дефолты) -### C1. resize-png: дефолт 0×0 → осмысленный +### C1. Source-aware defaults для dimension-полей -`geometry.ts`: `width: 0, height: 0` → `width: 512, height: 512` (или -`width: imgWidth, height: imgHeight` — но дефолт в схеме не может знать размер -изображения). **Решение:** дефолт `512×512`. +Q5 добавил общий `defaultFromSource` resolver в `registry-schema`. Для +`resize-png` и `crop-png` дефолт после загрузки равен размерам текущего source; +до загрузки используется положительный fallback `1×1`. Один-sided `0` у resize +сохраняет режим auto. ### C2. symmetric-copy-png: "keep side" не работает @@ -198,19 +199,17 @@ buttons: (s: Omit, "kind">): Field => ({ значение. Пока достаточно в `run()`: если `axis === "vertical"` и `keepSide` ∈ {top, bottom} → заменить на `left`. -### C3. Механизм проброса размеров картинки в схему +### C3. Source-aware bounds (отдельный следующий шаг) -**Проблема.** Поля, значения которых зависят от размеров исходника (crop x/y, -resize, offsets), объявлены в статической схеме (`-100000…100000`), а реальная -картинка обычно 800–4000px. Диапазон надо считать от фактического размера -изображения, а не от потолков. +Q5 реализовал только defaults dimension-полей через `SchemaContext` и +`defaultFromSource`. Динамические min/max для crop x/y, shift и resize остаются +отдельной задачей: bounds не следует смешивать с default resolver. **Что нужно спроектировать и завести:** 1. **Где берём размер.** `SchemaToolView` уже знает размер после `decodeFile()` - → `PixelImage` (width/height). Сейчас он никуда не пробрасывается — надо - прокинуть в `SchemaFields` и дальше в контролы, например через context или - проп `sourceSize`. + → `PixelImage` (width/height); для bounds понадобится передать этот context в + `SchemaFields` и контролы. 2. **Как схеме описать зависимость.** В спеках добавить ссылку на размер источника вместо жёстких чисел. Кандидат: @@ -228,15 +227,13 @@ resize, offsets), объявлены в статической схеме (`-100 3. **Что делает RangeControl.** При `bound` вычисляет min/max из текущего размера источника; при смене картинки диапазон пересчитывается, текущее - значение пере-клампится. Дефолт и `sanitize` остаются на статической основе - (или тоже учат bound) — решить при проектировании. + значение пере-клампится. 4. **Первые потребители:** crop (x/y → ±sourceWidth/sourceHeight), shift (offsetX/offsetY), resize (max → maxSide). -Это отдельный шаг плана: **завести механизм**, спроектировать на ревью интерфейс -спека (`bound` vs `range: (size) => [min, max]`), затем применить к crop и -остальным полям размерности. Пока механизма нет — поля crop остаются как есть. +Это отдельный refactor после Q6/Q7; source-aware defaults из Q5 не заменяют +bounds. ### C4. verify-is-png: неверное название diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index ebe931a..03c9e88 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -65,8 +65,8 @@ CSS-классы и конкретный формат временного URL helpers, а не повторять raw-селекторы. `test.fixme` допускается только для зарегистрированной проблемы в backlog или -checklist. В комментарии указывается ссылка на неё; Q4 не исправляет продуктовое -поведение. После исправления тест и ссылка обновляются вместе. +checklist. В комментарии указывается ссылка на неё. После исправления тест и +ссылка обновляются вместе; в текущем Q5 baseline активных fixme нет. ## 6. Проверки diff --git a/docs/tools-map.md b/docs/tools-map.md index f1f4d9f..d26be71 100644 --- a/docs/tools-map.md +++ b/docs/tools-map.md @@ -87,8 +87,9 @@ ### Геометрия -- resize-png — width (0=авто), height (0=авто), keepAspect -- crop-png — x, y, width, height +- resize-png — width/height (размеры source по умолчанию; 0=авто для одной + стороны), keepAspect +- crop-png — x, y, width/height (полная область source по умолчанию) - rotate-png — angle (90/180/270) - flip-png — axis (h/v) - skew-png — degX, degY diff --git a/web/e2e/generators.spec.ts b/web/e2e/generators.spec.ts index 17857b8..032907f 100644 --- a/web/e2e/generators.spec.ts +++ b/web/e2e/generators.spec.ts @@ -1,23 +1,29 @@ import { expect, test } from "playwright/test"; import { + expectNoErrorAlert, expectNoErrors, openTool, resultImage, trackErrors, } from "./helpers/page"; -test.describe("generators — known UI gap (#checklist, п.1)", () => { - // FIXME: Генераторы открываются, но в UI нет ни полей схемы, ни кнопки - // «Generate» — только RU/EN/Reset. Задача Q5: `docs/backlog.md`, - // `docs/checklist-manual-testing.md`. После фикса убрать fixme. - test.fixme("single-color-png renders Generate controls and produces a result", async ({ +test.describe("generators", () => { + test("single-color-png auto-runs and reacts to parameters", async ({ page, }) => { + const sink = trackErrors(page); await openTool(page, "single-color-png"); - const generate = page.getByRole("button", { name: "Generate" }); - await expect(generate).toBeVisible(); - await generate.click(); await expect(resultImage(page)).toBeVisible(); + await expect(page.getByRole("button", { name: "Generate" })).toHaveCount(0); + + const firstSource = await resultImage(page).getAttribute("src"); + await page.getByRole("spinbutton", { name: "Width" }).fill("32"); + await expect(resultImage(page)).not.toHaveAttribute( + "src", + firstSource ?? "", + ); + await expectNoErrorAlert(page); + expectNoErrors(sink); }); test("create-empty-png page opens without errors", async ({ page }) => { diff --git a/web/e2e/i18n.spec.ts b/web/e2e/i18n.spec.ts index e6e46a8..2a4a2a6 100644 --- a/web/e2e/i18n.spec.ts +++ b/web/e2e/i18n.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "playwright/test"; -import { opaquePng } from "./helpers/fixtures"; -import { openTool, toolLink, uploadImage, useEnglish } from "./helpers/page"; +import { pixelRow } from "./helpers/fixtures"; +import { openTool, toolLink, useEnglish } from "./helpers/page"; test("поиск каталога находит по названию из другой локали", async ({ page, @@ -31,11 +31,21 @@ test("RU/EN: переключение переводит заголовок ин await expect(heading).toHaveText("Resize PNG"); }); -test("ошибка run'а локализуется, не сырой i18n-ключ", async ({ page }) => { - await openTool(page, "resize-png"); - await uploadImage(page, opaquePng); +test("ошибка run'а локализуется и переводится при смене языка", async ({ + page, +}) => { + await openTool(page, "bytes-to-png"); + await page + .getByRole("textbox", { name: "Text data" }) + .fill(pixelRow(33, [255, 0, 0, 255])); + await page.getByRole("button", { name: "Render text" }).click(); const alert = page.getByRole("alert"); await expect(alert).toBeVisible(); await expect(alert).toHaveText(/\S/); await expect(alert).not.toHaveText(/^errors\./); + const english = await alert.textContent(); + + await page.getByRole("button", { name: "RU", exact: true }).click(); + await expect(alert).not.toHaveText(english ?? ""); + await expect(alert).not.toHaveText(/^errors\./); }); diff --git a/web/e2e/known-issues.spec.ts b/web/e2e/known-issues.spec.ts deleted file mode 100644 index 45d78ea..0000000 --- a/web/e2e/known-issues.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test } from "playwright/test"; -import { opaquePng } from "./helpers/fixtures"; -import { openTool, resultImage, uploadImage } from "./helpers/page"; - -// FIXME: В файле зарегистрированы баги production UI (см. -// `docs/backlog.md` и `docs/checklist-manual-testing.md`, раздел G). Тела -// assert'ят ОЖИДАЕМОЕ поведение. Статус fixme означает «мы знаем, что сейчас -// падает»; когда баг починят — убрать fixme и тест станет зелёным «сам по -// себе». - -test.describe("known bugs — documented as fixme", () => { - test.fixme("resize-png: upload produces a resized result (no alert)", async ({ - page, - }) => { - await openTool(page, "resize-png"); - await uploadImage(page, opaquePng); - await expect(page.getByRole("alert")).toHaveCount(0); - await expect(resultImage(page)).toBeVisible(); - }); - - test.fixme("crop-png: upload produces a cropped result (no alert)", async ({ - page, - }) => { - await openTool(page, "crop-png"); - await uploadImage(page, opaquePng); - await expect(page.getByRole("alert")).toHaveCount(0); - await expect(resultImage(page)).toBeVisible(); - }); -}); diff --git a/web/e2e/pipeline.spec.ts b/web/e2e/pipeline.spec.ts index b91b7a3..e3dd332 100644 --- a/web/e2e/pipeline.spec.ts +++ b/web/e2e/pipeline.spec.ts @@ -8,7 +8,6 @@ import { } from "./helpers/fixtures"; import { downloadResult, - emptyState, errorAlert, expectNoErrorAlert, expectNoErrors, @@ -21,6 +20,36 @@ import { useEnglish, } from "./helpers/page"; +test("resize-png uses the source dimensions by default", async ({ page }) => { + const sink = trackErrors(page); + await openTool(page, "resize-png"); + await uploadImage(page, opaquePng); + await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue( + "64", + ); + await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue( + "48", + ); + await expect(resultImage(page)).toBeVisible(); + await expectNoErrorAlert(page); + expectNoErrors(sink); +}); + +test("crop-png starts with the full source area", async ({ page }) => { + const sink = trackErrors(page); + await openTool(page, "crop-png"); + await uploadImage(page, opaquePng); + await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue( + "64", + ); + await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue( + "48", + ); + await expect(resultImage(page)).toBeVisible(); + await expectNoErrorAlert(page); + expectNoErrors(sink); +}); + test("flip-png: full flow - upload, result, download", async ({ page }) => { const sink = trackErrors(page); await openTool(page, "flip-png"); @@ -41,14 +70,23 @@ test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => { expectNoErrors(sink); }); -test.fixme("reset clears result but keeps source", async ({ page }) => { - await openTool(page, "flip-png"); +test("reset restores defaults and re-runs with the source", async ({ + page, +}) => { + const sink = trackErrors(page); + await openTool(page, "blur-png"); await uploadImage(page, opaquePng); await expect(resultImage(page)).toBeVisible(); + const radius = rangeInput(page, /radius/i); + await radius.fill("20"); + await expect(radius).toHaveValue("20"); + await page.getByRole("button", { name: "Reset" }).click(); - await expect(resultImage(page)).toHaveCount(0); - await expect(emptyState(page)).toBeVisible(); + await expect(radius).toHaveValue("4"); await expect(sourceImage(page)).toBeVisible(); + await expect(resultImage(page)).toBeVisible(); + await expectNoErrorAlert(page); + expectNoErrors(sink); }); test("blur: changing slider updates result image", async ({ page }) => { diff --git a/web/src/lib/components/SchemaToolView.svelte b/web/src/lib/components/SchemaToolView.svelte index 8b75bed..61fadc4 100644 --- a/web/src/lib/components/SchemaToolView.svelte +++ b/web/src/lib/components/SchemaToolView.svelte @@ -2,7 +2,7 @@ import SchemaFields from "$lib/components/SchemaFields.svelte"; import SchemaPreview from "$lib/components/SchemaPreview.svelte"; import { debounce } from "$lib/core/debounce"; - import { ToolError } from "$lib/core/errors"; + import { ToolError, type ErrorVars } from "$lib/core/errors"; import { decodeFile, encode } from "$lib/core/io"; import type { PixelImage } from "$lib/core/types"; import { execute } from "$lib/executor"; @@ -14,6 +14,7 @@ import { t } from "$lib/i18n/t"; import type { FileResult, ToolEntry } from "$lib/registry"; import { + applySourceDefaults, defaultSchemaParams, sanitizeSchemaParams, type ToolSchema, @@ -23,6 +24,9 @@ interface Props { tool: ToolEntry; } + type DisplayError = + | { kind: "i18n"; key: string; vars?: ErrorVars } + | { kind: "plain"; text: string }; let { tool }: Props = $props(); const schema = $derived( @@ -42,9 +46,17 @@ undefined, ); let running = $state(false); - let error = $state(""); + let displayError = $state(null); let started = $state(false); + const errorText = $derived.by(() => { + const current = displayError; + if (!current) return ""; + return current.kind === "i18n" + ? t(current.key, current.vars) + : current.text; + }); + const debouncedRun = debounce(() => run(), 200); function init() { @@ -58,30 +70,38 @@ } function reset() { - values = schema ? defaultSchemaParams(schema) : {}; + values = schema + ? defaultSchemaParams(schema, { source: source ?? undefined }) + : {}; result = null; fileResult = null; textResult = null; verdictVars = undefined; - error = ""; + displayError = null; } async function handleFile(file: File) { - error = ""; + displayError = null; try { - source = await decodeFile(file); + const decoded = await decodeFile(file); + source = decoded; + if (schema) { + values = applySourceDefaults(schema, values, { source: decoded }); + } } catch (e) { - error = errorText(e); + displayError = toDisplayError(e); } } async function run() { if (!schema) return; - error = ""; + displayError = null; running = true; try { const out = await execute(tool, { - params: sanitizeSchemaParams(schema, values), + params: sanitizeSchemaParams(schema, values, { + source: source ?? undefined, + }), source: source ?? undefined, text: textSource || undefined, }); @@ -108,7 +128,7 @@ fileResult = null; } } catch (e) { - error = errorText(e); + displayError = toDisplayError(e); } finally { running = false; } @@ -144,7 +164,7 @@ try { await navigator.clipboard.writeText(copyValue); } catch (e) { - error = errorText(e); + displayError = toDisplayError(e); } } @@ -164,18 +184,21 @@ }); $effect(() => { - // TODO: can any edge case start infinite loop? if (!started) return; + if (inputMode === "image" && !source) return; + if (inputMode === "text" && !textSource.trim()) return; void values; void source; debouncedRun(); return () => debouncedRun.cancel(); }); - function errorText(e: unknown): string { - if (e instanceof ToolError) return t(e.key, e.vars); - if (e instanceof Error) return e.message; - return String(e); + function toDisplayError(e: unknown): DisplayError { + if (e instanceof ToolError) { + return { kind: "i18n", key: e.key, vars: e.vars }; + } + if (e instanceof Error) return { kind: "plain", text: e.message }; + return { kind: "plain", text: String(e) }; } @@ -215,7 +238,7 @@ {inputMode} {resultKind} {running} - {error} + error={errorText} onupload={handleFile} ontextsource={(textValue) => { textSource = textValue; diff --git a/web/src/lib/executor/executor.ts b/web/src/lib/executor/executor.ts index 53bccf4..a771da4 100644 --- a/web/src/lib/executor/executor.ts +++ b/web/src/lib/executor/executor.ts @@ -20,7 +20,9 @@ export async function execute( tool: ToolEntry, ctx: ExecuteContext, ): Promise { - const params = sanitizeSchemaParams(tool.schema, ctx.params); + const params = sanitizeSchemaParams(tool.schema, ctx.params, { + source: ctx.source, + }); if (tool.input === "image" && !ctx.source) { throw new ToolError("errors.sourceRequired"); } @@ -129,13 +131,17 @@ function ensureWorker(): Worker | null { } else if (payload.errorKey) { entry.reject(new ToolError(payload.errorKey, payload.errorVars)); } else { - entry.reject(new Error(payload.error ?? "errors.workerFailed")); + entry.reject( + payload.error + ? new Error(payload.error) + : new ToolError("errors.workerFailed"), + ); } }; candidate.onerror = () => { worker = null; for (const entry of pending.values()) { - entry.reject(new Error("errors.workerUnavailable")); + entry.reject(new ToolError("errors.workerUnavailable")); } pending.clear(); }; diff --git a/web/src/lib/executor/executor.worker.ts b/web/src/lib/executor/executor.worker.ts index 8cac5df..87c321c 100644 --- a/web/src/lib/executor/executor.worker.ts +++ b/web/src/lib/executor/executor.worker.ts @@ -46,7 +46,7 @@ async function handle(request: WorkerRequest): Promise { try { const tool = getTool(request.toolId); if (!tool?.run) { - throw new Error("errors.noImageRun"); + throw new ToolError("errors.noImageRun"); } const source: PixelImage | undefined = request.source ? { @@ -56,7 +56,7 @@ async function handle(request: WorkerRequest): Promise { } : undefined; const output = await tool.run({ - params: sanitizeSchemaParams(tool.schema, request.params), + params: sanitizeSchemaParams(tool.schema, request.params, { source }), source, text: request.text, }); diff --git a/web/src/lib/i18n/i18n.test.ts b/web/src/lib/i18n/i18n.test.ts index 25bfd29..e47215d 100644 --- a/web/src/lib/i18n/i18n.test.ts +++ b/web/src/lib/i18n/i18n.test.ts @@ -36,6 +36,17 @@ describe("t", () => { expect(t("home.onlyEnKey")).toBe("Only English string"); }); + it("переводит ошибки с vars в активной локали", () => { + setLocale("ru"); + expect(t("errors.pixelCountMismatch", { count: 33, width: 32 })).toContain( + "33", + ); + setLocale("en"); + expect(t("errors.pixelCountMismatch", { count: 33, width: 32 })).toContain( + "33", + ); + }); + it("неизвестный путь возвращает сам путь", () => { expect(t("no.such.key")).toBe("no.such.key"); }); diff --git a/web/src/lib/registry-schema.test.ts b/web/src/lib/registry-schema.test.ts index d0df977..b787e9f 100644 --- a/web/src/lib/registry-schema.test.ts +++ b/web/src/lib/registry-schema.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { + applySourceDefaults, defaultSchemaParams, field, sanitizeSchemaParams, toolSchema, + type Dimension, } from "./registry-schema"; interface FrameParams { @@ -32,6 +34,24 @@ const frameSchema = toolSchema( }, ); +interface SourceParams { + size: Dimension; + fixed: Dimension; +} + +const sourceSchema = toolSchema({ + size: field.dimension({ + min: 1, + max: 100, + width: 1, + height: 1, + defaultFromSource: true, + }), + fixed: field.dimension({ min: 1, max: 100, width: 10, height: 20 }), +}); + +const source = { width: 64, height: 48 }; + describe("registry-schema: дефолты из схемы", () => { it("собирает дефолты всех полей", () => { expect(defaultSchemaParams(frameSchema)).toEqual({ @@ -43,6 +63,51 @@ describe("registry-schema: дефолты из схемы", () => { }); }); +describe("registry-schema: source-aware dimension defaults", () => { + it("uses static fallback before a source exists", () => { + expect(defaultSchemaParams(sourceSchema)).toEqual({ + size: { width: 1, height: 1 }, + fixed: { width: 10, height: 20 }, + }); + }); + + it("uses source dimensions for opted-in fields only", () => { + expect(defaultSchemaParams(sourceSchema, { source })).toEqual({ + size: source, + fixed: { width: 10, height: 20 }, + }); + expect( + applySourceDefaults( + sourceSchema, + { size: { width: 2, height: 3 }, fixed: { width: 30, height: 40 } }, + { source }, + ), + ).toEqual({ + size: source, + fixed: { width: 30, height: 40 }, + }); + }); + + it("uses source dimensions when sanitizing missing values", () => { + expect(sanitizeSchemaParams(sourceSchema, {}, { source })).toEqual({ + size: source, + fixed: { width: 10, height: 20 }, + }); + const manual = sanitizeSchemaParams( + sourceSchema, + { size: { width: 12, height: 13 } }, + { source }, + ); + expect(manual.size).toEqual({ width: 12, height: 13 }); + const sentinel = sanitizeSchemaParams( + sourceSchema, + { size: { width: 0, height: 0 } }, + { source }, + ); + expect(sentinel.size).toEqual(source); + }); +}); + describe("registry-schema: раскладка (schema.layout)", () => { it("toolSchema сохраняет группы полей", () => { expect(frameSchema.layout).toEqual({ diff --git a/web/src/lib/registry-schema.ts b/web/src/lib/registry-schema.ts index 126df3c..35f269f 100644 --- a/web/src/lib/registry-schema.ts +++ b/web/src/lib/registry-schema.ts @@ -69,6 +69,11 @@ export interface DimensionSpec extends FieldSpecBase { max: number; width: number; height: number; + defaultFromSource?: boolean; +} + +export interface SchemaContext { + source?: Dimension; } /** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */ @@ -270,6 +275,7 @@ export const field = { max: number; width: number; height: number; + defaultFromSource?: boolean; }): Field => ({ spec: { kind: "dimension", ...s }, }), @@ -341,15 +347,27 @@ function clamp(value: number, min?: number, max?: number): number { return value; } +function resolveDimensionDefaults( + spec: DimensionSpec, + context?: SchemaContext, +): Dimension { + const source = spec.defaultFromSource ? context?.source : undefined; + return { + width: clamp(source?.width ?? spec.width, spec.min, spec.max), + height: clamp(source?.height ?? spec.height, spec.min, spec.max), + }; +} + /** Дефолты из схемы — единый источник значений по умолчанию для нового UI. */ export function defaultSchemaParams

( schema: ToolSchema

, + context?: SchemaContext, ): Record { const out = {} as Record; for (const key of Object.keys(schema.fields) as (keyof P)[]) { const spec = schema.fields[key].spec; if (spec.kind === "dimension") { - out[key] = { width: spec.width, height: spec.height }; + out[key] = resolveDimensionDefaults(spec, context); } else if (spec.kind === "color-pair") { out[key] = { from: spec.from, to: spec.to }; } else if (spec.kind === "colors") { @@ -382,10 +400,27 @@ export function defaultSchemaParams

( return out; } +export function applySourceDefaults

( + schema: ToolSchema

, + values: Record, + context: SchemaContext, +): Record { + const defaults = defaultSchemaParams(schema, context); + for (const key of Object.keys(schema.fields) as (keyof P)[]) { + const spec = schema.fields[key].spec; + if (spec.kind === "dimension" && spec.defaultFromSource) continue; + if (Object.prototype.hasOwnProperty.call(values, key)) { + defaults[key] = values[key as string]; + } + } + return defaults; +} + /** Валидация/нормализация значений по схеме. Аналог старого `sanitizeParams`. */ export function sanitizeSchemaParams

( schema: ToolSchema

, values: Record, + context?: SchemaContext, ): Record { const out = {} as Record; for (const key of Object.keys(schema.fields) as (keyof P)[]) { @@ -425,17 +460,22 @@ export function sanitizeSchemaParams

( "height" in raw ? (raw as Record) : undefined; + const defaults = resolveDimensionDefaults(spec, context); const w = typeof r?.width === "number" && Number.isFinite(r.width) ? r.width - : spec.width; + : defaults.width; const h = typeof r?.height === "number" && Number.isFinite(r.height) ? r.height - : spec.height; + : defaults.height; + const sourceSize = + spec.defaultFromSource && context?.source && w === 0 && h === 0 + ? defaults + : { width: w, height: h }; out[key] = { - width: clamp(w, spec.min, spec.max), - height: clamp(h, spec.min, spec.max), + width: clamp(sourceSize.width, spec.min, spec.max), + height: clamp(sourceSize.height, spec.min, spec.max), }; break; } diff --git a/web/src/lib/registry/geometry.ts b/web/src/lib/registry/geometry.ts index 1a4aab3..e5b7bd0 100644 --- a/web/src/lib/registry/geometry.ts +++ b/web/src/lib/registry/geometry.ts @@ -194,8 +194,9 @@ export const resizeSchema = toolSchema( label: "fields.canvasSize", min: 0, max: 20000, - width: 0, - height: 0, + width: 1, + height: 1, + defaultFromSource: true, }), keepAspect: field.checkbox({ label: "fields.keepAspect", default: true }), }, @@ -263,10 +264,11 @@ export const cropSchema = toolSchema( }), size: field.dimension({ label: "fields.cropAreaSize", - min: 0, + min: 1, max: 2000, - width: 0, - height: 0, + width: 1, + height: 1, + defaultFromSource: true, }), }, { diff --git a/web/src/lib/registry/registry.test.ts b/web/src/lib/registry/registry.test.ts index 09b5303..94391c8 100644 --- a/web/src/lib/registry/registry.test.ts +++ b/web/src/lib/registry/registry.test.ts @@ -65,6 +65,45 @@ describe("registry-new (переведённые инструменты)", () => } }); + it("source-aware dimension defaults применяются только к image tools", () => { + for (const tool of TOOLS) { + for (const field of Object.values(tool.schema.fields)) { + if (field.spec.kind === "dimension" && field.spec.defaultFromSource) { + expect(tool.input, tool.id).toBe("image"); + } + } + } + }); + + it("resize и crop получают размеры текущего source", async () => { + const source = solid(64, 48); + const resize = TOOLS.find((t) => t.id === "resize-png")!; + const resizeDefaults = defaultSchemaParams(resize.schema, { source }); + expect(resizeDefaults.size).toEqual({ width: 64, height: 48 }); + const resized = asImage( + await resize.run({ + source, + params: sanitizeSchemaParams(resize.schema, resizeDefaults, { source }), + }), + ); + expect([resized.width, resized.height]).toEqual([64, 48]); + + const crop = TOOLS.find((t) => t.id === "crop-png")!; + const cropDefaults = defaultSchemaParams(crop.schema, { source }); + expect(cropDefaults).toMatchObject({ + x: 0, + y: 0, + size: { width: 64, height: 48 }, + }); + const cropped = asImage( + await crop.run({ + source, + params: sanitizeSchemaParams(crop.schema, cropDefaults, { source }), + }), + ); + expect([cropped.width, cropped.height]).toEqual([64, 48]); + }); + it("create-empty: dimension-дефолты корректны", () => { const tool = TOOLS.find((t) => t.id === "create-empty-png")!; const d = defaultSchemaParams(tool.schema);