mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: add source aware resolver, update reset, update generator
This commit is contained in:
+4
-4
@@ -51,17 +51,17 @@
|
|||||||
только если темы нет в существующем документе; иначе обновляется источник
|
только если темы нет в существующем документе; иначе обновляется источник
|
||||||
истины.
|
истины.
|
||||||
|
|
||||||
## Текущий baseline Q4
|
## Текущий baseline Q5
|
||||||
|
|
||||||
На момент фиксации Q4:
|
На момент фиксации Q5:
|
||||||
|
|
||||||
- production registry содержит 122 инструмента;
|
- production registry содержит 122 инструмента;
|
||||||
- `pnpm --dir web check` проходит без ошибок и предупреждений;
|
- `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 test:rules` — 4 test files / 74 tests;
|
||||||
- `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused
|
- `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused
|
||||||
design tokens от token audit;
|
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`.
|
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`.
|
||||||
|
|
||||||
|
|||||||
+9
-29
@@ -105,37 +105,17 @@
|
|||||||
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
|
||||||
(после wasm-фаз 5–7)
|
(после wasm-фаз 5–7)
|
||||||
|
|
||||||
## Баги preview
|
## Качество production UI
|
||||||
|
|
||||||
Зарегистрированы при написании e2e (`web/e2e/`, `pnpm --dir web test:e2e`) и
|
Q5 закрывает известные preview-проблемы; активных `test.fixme` в e2e больше нет.
|
||||||
ручного чек-листа — см. `docs/checklist-manual-testing.md` (раздел G). Каждый
|
|
||||||
пункт связан с соответствующим `test.fixme` или отдельным тестовым долгом. После
|
|
||||||
фикса: убрать `test.fixme` и отметить пункт G в чек-листе как пройденный.
|
|
||||||
|
|
||||||
1. **Генераторы без автоматического результата** — все 21 инструмент категории
|
- [x] Генераторы auto-run без кнопки Generate — `web/e2e/generators.spec.ts`.
|
||||||
GENERATE открываются, но в UI нет полей схемы и результата; доступны только
|
- [x] `resize-png` и `crop-png` используют размеры текущего source через общий
|
||||||
RU/EN/Reset. В `SchemaToolView.svelte` уже есть auto-run через debounce,
|
source-aware schema resolver — `web/e2e/pipeline.spec.ts`.
|
||||||
поэтому отдельная кнопка Generate не требуется. Ожидание: поля схемы,
|
- [x] Reset сохраняет source/text, восстанавливает defaults и запускает deferred
|
||||||
автоматический запуск по дефолтам и результат-картинка. Fixme:
|
auto-run — `web/e2e/pipeline.spec.ts`.
|
||||||
`web/e2e/generators.spec.ts`. Кнопка Generate не добавляется без отдельного
|
- [x] Ошибки хранят i18n key/vars и переводятся при выводе; изменение языка
|
||||||
UX-решения.
|
обновляет alert — `web/e2e/i18n.spec.ts`.
|
||||||
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`.
|
|
||||||
|
|
||||||
## Идеи
|
## Идеи
|
||||||
|
|
||||||
|
|||||||
@@ -95,24 +95,19 @@
|
|||||||
корректный (не «кракозябры»).
|
корректный (не «кракозябры»).
|
||||||
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
|
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
|
||||||
|
|
||||||
## G. Известные баги preview
|
## G. Закрытые Q5-проблемы
|
||||||
|
|
||||||
> Список синхронизирован с `docs/backlog.md`. Каждый пункт связан с
|
> Q5-проблемы закрыты автоматическими тестами; для ручной проверки остаются
|
||||||
> зарегистрированной проблемой или соответствующим `test.fixme`; после
|
> субъективные сценарии разделов A–F.
|
||||||
> исправления отметить его здесь и убрать `test.fixme` в том же изменении.
|
|
||||||
|
|
||||||
- [ ] **Генераторы:** открыть инструмент категории GENERATE, проверить поля
|
- [x] **Генераторы:** auto-run по дефолтам, результат и изменение параметра без
|
||||||
схемы, автоматический запуск по дефолтам и появление результата; кнопка
|
кнопки Generate. `web/e2e/generators.spec.ts`.
|
||||||
Generate не требуется. `docs/backlog.md`, `web/e2e/generators.spec.ts`.
|
- [x] **resize-png/crop-png:** размеры текущего source используются по
|
||||||
- [ ] **resize-png:** загрузить изображение и получить результат без alert.
|
умолчанию, alert не появляется. `web/e2e/pipeline.spec.ts`.
|
||||||
`docs/backlog.md`, `web/e2e/known-issues.spec.ts`.
|
- [x] **Reset:** defaults восстанавливаются, source сохраняется, deferred
|
||||||
- [ ] **crop-png:** загрузить изображение и получить осмысленный результат без
|
auto-run завершается результатом. `web/e2e/pipeline.spec.ts`.
|
||||||
alert. `docs/backlog.md`, `web/e2e/known-issues.spec.ts`.
|
- [x] **Локализация ошибок:** alert хранит key/vars, переводится при выводе и
|
||||||
- [ ] **Локализация ошибок:** получить человекочитаемое сообщение на текущем
|
обновляется при смене языка. `web/e2e/i18n.spec.ts`.
|
||||||
языке вместо сырого i18n-ключа. `docs/backlog.md`, `web/e2e/i18n.spec.ts`.
|
|
||||||
- [ ] **Reset/auto-run:** проверить выбранную семантику Reset и автоматический
|
|
||||||
перезапуск после изменения параметров. `docs/backlog.md`,
|
|
||||||
`web/e2e/pipeline.spec.ts`.
|
|
||||||
|
|
||||||
## Как долго
|
## Как долго
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# План: линтеры, тесты и качество кодовой базы
|
# План: линтеры, тесты и качество кодовой базы
|
||||||
|
|
||||||
> Статус: **draft — Q0–Q4 готовы к ревью; Q5 — следующий этап**.
|
> Статус: **draft — Q0–Q5 готовы к ревью; Q6 — следующий этап**.
|
||||||
>
|
>
|
||||||
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
|
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
|
||||||
> инфраструктурных скриптов. Q0–Q4 выполнены; Q5–Q8 выполняются отдельными
|
> инфраструктурных скриптов. Q0–Q5 выполнены; Q6–Q8 выполняются отдельными
|
||||||
> атомарными шагами.
|
> атомарными шагами.
|
||||||
>
|
>
|
||||||
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
|
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
|
||||||
@@ -45,14 +45,10 @@
|
|||||||
docs и production build; deploy выполняет его перед сборкой.
|
docs и production build; deploy выполняет его перед сборкой.
|
||||||
- `i18n/dict-consistency` даёт 0 warnings на текущем baseline; отдельный debt —
|
- `i18n/dict-consistency` даёт 0 warnings на текущем baseline; отдельный debt —
|
||||||
4 unused design tokens от token audit.
|
4 unused design tokens от token audit.
|
||||||
- В e2e остаются фиксированные числа, точные тексты, CSS-классы и
|
- E2E-контракт использует semantic locators и общий source-aware schema
|
||||||
`alt`-селекторы: `web/e2e/catalog.spec.ts:4-55`,
|
resolver; активных known-issue `test.fixme` нет.
|
||||||
`web/e2e/text-and-verdicts.spec.ts:52-101`.
|
- `ToolError` хранится как key/vars и переводится только при выводе; смена
|
||||||
- Backlog и реализация расходятся по генераторам: продуктовая заметка требует
|
локали обновляет уже видимый alert.
|
||||||
автоматический запуск без `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`).
|
|
||||||
|
|
||||||
## 3. Этапы выполнения
|
## 3. Этапы выполнения
|
||||||
|
|
||||||
@@ -175,27 +171,29 @@ pnpm --dir web test:e2e
|
|||||||
|
|
||||||
**Результат:** изменение формулировки, класса или порядка элементов не ломает
|
**Результат:** изменение формулировки, класса или порядка элементов не ломает
|
||||||
поведенческий тест без изменения пользовательского контракта; следующий этап —
|
поведенческий тест без изменения пользовательского контракта; следующий этап —
|
||||||
Q5.
|
Q6.
|
||||||
|
|
||||||
### Q5. Закрыть известные preview-баги
|
### Q5. Закрыть известные preview-баги
|
||||||
|
|
||||||
**Оценка:** M. **Зависимости:** Q4.
|
**Статус:** выполнен. **Проверено:** unit, Q5 E2E smoke и полный `pnpm verify`
|
||||||
|
проходят; активных known-issue `test.fixme` нет. **Оценка:** M–L.
|
||||||
|
**Зависимости:** Q4.
|
||||||
|
|
||||||
1. Выбрать поведение `resize-png` и `crop-png` при загрузке изображения:
|
- Добавлен общий opt-in `defaultFromSource` resolver в `registry-schema`: он
|
||||||
source-aware defaults, осмысленные статические defaults или отдельный
|
принимает source dimensions, обновляет только source-aware поля при upload и
|
||||||
автоматический repair.
|
используется в UI, executor и worker. `resize-png` и `crop-png` получают
|
||||||
2. Исправить registry/schema и только после этого снять соответствующие
|
размеры текущего source; static/generator/text schemas не затронуты.
|
||||||
`test.fixme`.
|
- Reset сохраняет source/text, восстанавливает source-aware defaults и запускает
|
||||||
3. Зафиксировать auto-run для генераторов и переписать e2e под фактическое
|
deferred auto-run через debounce.
|
||||||
автоматическое поведение; кнопку Generate не добавлять без отдельного UX
|
- Генераторы проверены в auto-run без кнопки Generate; кнопка не добавляется.
|
||||||
решения.
|
- `ToolError` хранится как key/vars, переводится derived-значением при выводе и
|
||||||
4. Определить семантику Reset: очистка результата, немедленный повторный run или
|
обновляется при смене локали. E2E использует независимый invalid pixel-count
|
||||||
временное отключение auto-run.
|
сценарий.
|
||||||
5. Перенести проверку локализованной ошибки на независимый детерминированный
|
- Resize/crop, generator, Reset и localization fixme сняты; Q6 начинает
|
||||||
invalid-input сценарий.
|
расширение покрытия, а не исправление этих контрактов.
|
||||||
|
|
||||||
**Готово, когда:** каждый known issue либо исправлен и покрыт зелёным тестом,
|
**Результат:** Q5 known issues закрыты и отражены в `docs/backlog.md` и
|
||||||
либо явно перенесён в отдельную продуктовую задачу с владельцем и критерием.
|
`docs/checklist-manual-testing.md`; следующий этап — Q6.
|
||||||
|
|
||||||
### Q6. Расширение unit и browser-покрытия
|
### Q6. Расширение unit и browser-покрытия
|
||||||
|
|
||||||
@@ -373,9 +371,9 @@ refactor, новые i18n-правила и исправление продук
|
|||||||
|
|
||||||
## 7. Решения, которые нужно принять на ревью
|
## 7. Решения, которые нужно принять на ревью
|
||||||
|
|
||||||
- Какой default выбрать для `resize-png` и `crop-png` при загрузке?
|
- Какие executor/worker/fallback сценарии войдут в первый Q6 coverage PR?
|
||||||
- Подтверждаем ли auto-run генераторов без кнопки Generate?
|
- Какие special PNG fixtures обязательны для baseline?
|
||||||
- Что именно должен делать Reset при включённом auto-run?
|
- Нужны ли browser/mobile matrix и visual snapshots до coverage thresholds?
|
||||||
- Какой независимый invalid-input сценарий заменит resize-dependent error test?
|
- Какие unused design tokens можно удалить, а какие должны остаться?
|
||||||
- Какие browser/e2e проверки должны быть обязательными для PR, а какие —
|
- Какие browser/e2e проверки должны быть обязательными для PR, а какие —
|
||||||
отдельным ночным прогоном?
|
отдельным ночным прогоном?
|
||||||
|
|||||||
@@ -181,11 +181,12 @@ buttons: <V extends string>(s: Omit<ButtonsSpec<V>, "kind">): Field<V> => ({
|
|||||||
|
|
||||||
## 3. Волна C — обязательные фиксы (баги + дефолты)
|
## 3. Волна C — обязательные фиксы (баги + дефолты)
|
||||||
|
|
||||||
### C1. resize-png: дефолт 0×0 → осмысленный
|
### C1. Source-aware defaults для dimension-полей
|
||||||
|
|
||||||
`geometry.ts`: `width: 0, height: 0` → `width: 512, height: 512` (или
|
Q5 добавил общий `defaultFromSource` resolver в `registry-schema`. Для
|
||||||
`width: imgWidth, height: imgHeight` — но дефолт в схеме не может знать размер
|
`resize-png` и `crop-png` дефолт после загрузки равен размерам текущего source;
|
||||||
изображения). **Решение:** дефолт `512×512`.
|
до загрузки используется положительный fallback `1×1`. Один-sided `0` у resize
|
||||||
|
сохраняет режим auto.
|
||||||
|
|
||||||
### C2. symmetric-copy-png: "keep side" не работает
|
### C2. symmetric-copy-png: "keep side" не работает
|
||||||
|
|
||||||
@@ -198,19 +199,17 @@ buttons: <V extends string>(s: Omit<ButtonsSpec<V>, "kind">): Field<V> => ({
|
|||||||
значение. Пока достаточно в `run()`: если `axis === "vertical"` и `keepSide` ∈
|
значение. Пока достаточно в `run()`: если `axis === "vertical"` и `keepSide` ∈
|
||||||
{top, bottom} → заменить на `left`.
|
{top, bottom} → заменить на `left`.
|
||||||
|
|
||||||
### C3. Механизм проброса размеров картинки в схему
|
### C3. Source-aware bounds (отдельный следующий шаг)
|
||||||
|
|
||||||
**Проблема.** Поля, значения которых зависят от размеров исходника (crop x/y,
|
Q5 реализовал только defaults dimension-полей через `SchemaContext` и
|
||||||
resize, offsets), объявлены в статической схеме (`-100000…100000`), а реальная
|
`defaultFromSource`. Динамические min/max для crop x/y, shift и resize остаются
|
||||||
картинка обычно 800–4000px. Диапазон надо считать от фактического размера
|
отдельной задачей: bounds не следует смешивать с default resolver.
|
||||||
изображения, а не от потолков.
|
|
||||||
|
|
||||||
**Что нужно спроектировать и завести:**
|
**Что нужно спроектировать и завести:**
|
||||||
|
|
||||||
1. **Где берём размер.** `SchemaToolView` уже знает размер после `decodeFile()`
|
1. **Где берём размер.** `SchemaToolView` уже знает размер после `decodeFile()`
|
||||||
→ `PixelImage` (width/height). Сейчас он никуда не пробрасывается — надо
|
→ `PixelImage` (width/height); для bounds понадобится передать этот context в
|
||||||
прокинуть в `SchemaFields` и дальше в контролы, например через context или
|
`SchemaFields` и контролы.
|
||||||
проп `sourceSize`.
|
|
||||||
|
|
||||||
2. **Как схеме описать зависимость.** В спеках добавить ссылку на размер
|
2. **Как схеме описать зависимость.** В спеках добавить ссылку на размер
|
||||||
источника вместо жёстких чисел. Кандидат:
|
источника вместо жёстких чисел. Кандидат:
|
||||||
@@ -228,15 +227,13 @@ resize, offsets), объявлены в статической схеме (`-100
|
|||||||
|
|
||||||
3. **Что делает RangeControl.** При `bound` вычисляет min/max из текущего
|
3. **Что делает RangeControl.** При `bound` вычисляет min/max из текущего
|
||||||
размера источника; при смене картинки диапазон пересчитывается, текущее
|
размера источника; при смене картинки диапазон пересчитывается, текущее
|
||||||
значение пере-клампится. Дефолт и `sanitize` остаются на статической основе
|
значение пере-клампится.
|
||||||
(или тоже учат bound) — решить при проектировании.
|
|
||||||
|
|
||||||
4. **Первые потребители:** crop (x/y → ±sourceWidth/sourceHeight), shift
|
4. **Первые потребители:** crop (x/y → ±sourceWidth/sourceHeight), shift
|
||||||
(offsetX/offsetY), resize (max → maxSide).
|
(offsetX/offsetY), resize (max → maxSide).
|
||||||
|
|
||||||
Это отдельный шаг плана: **завести механизм**, спроектировать на ревью интерфейс
|
Это отдельный refactor после Q6/Q7; source-aware defaults из Q5 не заменяют
|
||||||
спека (`bound` vs `range: (size) => [min, max]`), затем применить к crop и
|
bounds.
|
||||||
остальным полям размерности. Пока механизма нет — поля crop остаются как есть.
|
|
||||||
|
|
||||||
### C4. verify-is-png: неверное название
|
### C4. verify-is-png: неверное название
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ CSS-классы и конкретный формат временного URL
|
|||||||
helpers, а не повторять raw-селекторы.
|
helpers, а не повторять raw-селекторы.
|
||||||
|
|
||||||
`test.fixme` допускается только для зарегистрированной проблемы в backlog или
|
`test.fixme` допускается только для зарегистрированной проблемы в backlog или
|
||||||
checklist. В комментарии указывается ссылка на неё; Q4 не исправляет продуктовое
|
checklist. В комментарии указывается ссылка на неё. После исправления тест и
|
||||||
поведение. После исправления тест и ссылка обновляются вместе.
|
ссылка обновляются вместе; в текущем Q5 baseline активных fixme нет.
|
||||||
|
|
||||||
## 6. Проверки
|
## 6. Проверки
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -87,8 +87,9 @@
|
|||||||
|
|
||||||
### Геометрия
|
### Геометрия
|
||||||
|
|
||||||
- resize-png — width (0=авто), height (0=авто), keepAspect
|
- resize-png — width/height (размеры source по умолчанию; 0=авто для одной
|
||||||
- crop-png — x, y, width, height
|
стороны), keepAspect
|
||||||
|
- crop-png — x, y, width/height (полная область source по умолчанию)
|
||||||
- rotate-png — angle (90/180/270)
|
- rotate-png — angle (90/180/270)
|
||||||
- flip-png — axis (h/v)
|
- flip-png — axis (h/v)
|
||||||
- skew-png — degX, degY
|
- skew-png — degX, degY
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
import {
|
import {
|
||||||
|
expectNoErrorAlert,
|
||||||
expectNoErrors,
|
expectNoErrors,
|
||||||
openTool,
|
openTool,
|
||||||
resultImage,
|
resultImage,
|
||||||
trackErrors,
|
trackErrors,
|
||||||
} from "./helpers/page";
|
} from "./helpers/page";
|
||||||
|
|
||||||
test.describe("generators — known UI gap (#checklist, п.1)", () => {
|
test.describe("generators", () => {
|
||||||
// FIXME: Генераторы открываются, но в UI нет ни полей схемы, ни кнопки
|
test("single-color-png auto-runs and reacts to parameters", async ({
|
||||||
// «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 ({
|
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "single-color-png");
|
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(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 }) => {
|
test("create-empty-png page opens without errors", async ({ page }) => {
|
||||||
|
|||||||
+15
-5
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
import { opaquePng } from "./helpers/fixtures";
|
import { pixelRow } from "./helpers/fixtures";
|
||||||
import { openTool, toolLink, uploadImage, useEnglish } from "./helpers/page";
|
import { openTool, toolLink, useEnglish } from "./helpers/page";
|
||||||
|
|
||||||
test("поиск каталога находит по названию из другой локали", async ({
|
test("поиск каталога находит по названию из другой локали", async ({
|
||||||
page,
|
page,
|
||||||
@@ -31,11 +31,21 @@ test("RU/EN: переключение переводит заголовок ин
|
|||||||
await expect(heading).toHaveText("Resize PNG");
|
await expect(heading).toHaveText("Resize PNG");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ошибка run'а локализуется, не сырой i18n-ключ", async ({ page }) => {
|
test("ошибка run'а локализуется и переводится при смене языка", async ({
|
||||||
await openTool(page, "resize-png");
|
page,
|
||||||
await uploadImage(page, opaquePng);
|
}) => {
|
||||||
|
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");
|
const alert = page.getByRole("alert");
|
||||||
await expect(alert).toBeVisible();
|
await expect(alert).toBeVisible();
|
||||||
await expect(alert).toHaveText(/\S/);
|
await expect(alert).toHaveText(/\S/);
|
||||||
await expect(alert).not.toHaveText(/^errors\./);
|
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\./);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from "./helpers/fixtures";
|
} from "./helpers/fixtures";
|
||||||
import {
|
import {
|
||||||
downloadResult,
|
downloadResult,
|
||||||
emptyState,
|
|
||||||
errorAlert,
|
errorAlert,
|
||||||
expectNoErrorAlert,
|
expectNoErrorAlert,
|
||||||
expectNoErrors,
|
expectNoErrors,
|
||||||
@@ -21,6 +20,36 @@ import {
|
|||||||
useEnglish,
|
useEnglish,
|
||||||
} from "./helpers/page";
|
} 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 }) => {
|
test("flip-png: full flow - upload, result, download", async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "flip-png");
|
await openTool(page, "flip-png");
|
||||||
@@ -41,14 +70,23 @@ test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
|
|||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.fixme("reset clears result but keeps source", async ({ page }) => {
|
test("reset restores defaults and re-runs with the source", async ({
|
||||||
await openTool(page, "flip-png");
|
page,
|
||||||
|
}) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "blur-png");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
await expect(resultImage(page)).toBeVisible();
|
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 page.getByRole("button", { name: "Reset" }).click();
|
||||||
await expect(resultImage(page)).toHaveCount(0);
|
await expect(radius).toHaveValue("4");
|
||||||
await expect(emptyState(page)).toBeVisible();
|
|
||||||
await expect(sourceImage(page)).toBeVisible();
|
await expect(sourceImage(page)).toBeVisible();
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blur: changing slider updates result image", async ({ page }) => {
|
test("blur: changing slider updates result image", async ({ page }) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import SchemaFields from "$lib/components/SchemaFields.svelte";
|
import SchemaFields from "$lib/components/SchemaFields.svelte";
|
||||||
import SchemaPreview from "$lib/components/SchemaPreview.svelte";
|
import SchemaPreview from "$lib/components/SchemaPreview.svelte";
|
||||||
import { debounce } from "$lib/core/debounce";
|
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 { decodeFile, encode } from "$lib/core/io";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import { execute } from "$lib/executor";
|
import { execute } from "$lib/executor";
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import type { FileResult, ToolEntry } from "$lib/registry";
|
import type { FileResult, ToolEntry } from "$lib/registry";
|
||||||
import {
|
import {
|
||||||
|
applySourceDefaults,
|
||||||
defaultSchemaParams,
|
defaultSchemaParams,
|
||||||
sanitizeSchemaParams,
|
sanitizeSchemaParams,
|
||||||
type ToolSchema,
|
type ToolSchema,
|
||||||
@@ -23,6 +24,9 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
tool: ToolEntry;
|
tool: ToolEntry;
|
||||||
}
|
}
|
||||||
|
type DisplayError =
|
||||||
|
| { kind: "i18n"; key: string; vars?: ErrorVars }
|
||||||
|
| { kind: "plain"; text: string };
|
||||||
let { tool }: Props = $props();
|
let { tool }: Props = $props();
|
||||||
|
|
||||||
const schema = $derived(
|
const schema = $derived(
|
||||||
@@ -42,9 +46,17 @@
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
let running = $state(false);
|
let running = $state(false);
|
||||||
let error = $state("");
|
let displayError = $state<DisplayError | null>(null);
|
||||||
let started = $state(false);
|
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);
|
const debouncedRun = debounce(() => run(), 200);
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
@@ -58,30 +70,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
values = schema ? defaultSchemaParams(schema) : {};
|
values = schema
|
||||||
|
? defaultSchemaParams(schema, { source: source ?? undefined })
|
||||||
|
: {};
|
||||||
result = null;
|
result = null;
|
||||||
fileResult = null;
|
fileResult = null;
|
||||||
textResult = null;
|
textResult = null;
|
||||||
verdictVars = undefined;
|
verdictVars = undefined;
|
||||||
error = "";
|
displayError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
error = "";
|
displayError = null;
|
||||||
try {
|
try {
|
||||||
source = await decodeFile(file);
|
const decoded = await decodeFile(file);
|
||||||
|
source = decoded;
|
||||||
|
if (schema) {
|
||||||
|
values = applySourceDefaults(schema, values, { source: decoded });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = errorText(e);
|
displayError = toDisplayError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!schema) return;
|
if (!schema) return;
|
||||||
error = "";
|
displayError = null;
|
||||||
running = true;
|
running = true;
|
||||||
try {
|
try {
|
||||||
const out = await execute(tool, {
|
const out = await execute(tool, {
|
||||||
params: sanitizeSchemaParams(schema, values),
|
params: sanitizeSchemaParams(schema, values, {
|
||||||
|
source: source ?? undefined,
|
||||||
|
}),
|
||||||
source: source ?? undefined,
|
source: source ?? undefined,
|
||||||
text: textSource || undefined,
|
text: textSource || undefined,
|
||||||
});
|
});
|
||||||
@@ -108,7 +128,7 @@
|
|||||||
fileResult = null;
|
fileResult = null;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = errorText(e);
|
displayError = toDisplayError(e);
|
||||||
} finally {
|
} finally {
|
||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
@@ -144,7 +164,7 @@
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(copyValue);
|
await navigator.clipboard.writeText(copyValue);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = errorText(e);
|
displayError = toDisplayError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,18 +184,21 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// TODO: can any edge case start infinite loop?
|
|
||||||
if (!started) return;
|
if (!started) return;
|
||||||
|
if (inputMode === "image" && !source) return;
|
||||||
|
if (inputMode === "text" && !textSource.trim()) return;
|
||||||
void values;
|
void values;
|
||||||
void source;
|
void source;
|
||||||
debouncedRun();
|
debouncedRun();
|
||||||
return () => debouncedRun.cancel();
|
return () => debouncedRun.cancel();
|
||||||
});
|
});
|
||||||
|
|
||||||
function errorText(e: unknown): string {
|
function toDisplayError(e: unknown): DisplayError {
|
||||||
if (e instanceof ToolError) return t(e.key, e.vars);
|
if (e instanceof ToolError) {
|
||||||
if (e instanceof Error) return e.message;
|
return { kind: "i18n", key: e.key, vars: e.vars };
|
||||||
return String(e);
|
}
|
||||||
|
if (e instanceof Error) return { kind: "plain", text: e.message };
|
||||||
|
return { kind: "plain", text: String(e) };
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -215,7 +238,7 @@
|
|||||||
{inputMode}
|
{inputMode}
|
||||||
{resultKind}
|
{resultKind}
|
||||||
{running}
|
{running}
|
||||||
{error}
|
error={errorText}
|
||||||
onupload={handleFile}
|
onupload={handleFile}
|
||||||
ontextsource={(textValue) => {
|
ontextsource={(textValue) => {
|
||||||
textSource = textValue;
|
textSource = textValue;
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export async function execute(
|
|||||||
tool: ToolEntry,
|
tool: ToolEntry,
|
||||||
ctx: ExecuteContext,
|
ctx: ExecuteContext,
|
||||||
): Promise<ToolResult> {
|
): Promise<ToolResult> {
|
||||||
const params = sanitizeSchemaParams(tool.schema, ctx.params);
|
const params = sanitizeSchemaParams(tool.schema, ctx.params, {
|
||||||
|
source: ctx.source,
|
||||||
|
});
|
||||||
if (tool.input === "image" && !ctx.source) {
|
if (tool.input === "image" && !ctx.source) {
|
||||||
throw new ToolError("errors.sourceRequired");
|
throw new ToolError("errors.sourceRequired");
|
||||||
}
|
}
|
||||||
@@ -129,13 +131,17 @@ function ensureWorker(): Worker | null {
|
|||||||
} else if (payload.errorKey) {
|
} else if (payload.errorKey) {
|
||||||
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
|
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
|
||||||
} else {
|
} else {
|
||||||
entry.reject(new Error(payload.error ?? "errors.workerFailed"));
|
entry.reject(
|
||||||
|
payload.error
|
||||||
|
? new Error(payload.error)
|
||||||
|
: new ToolError("errors.workerFailed"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
candidate.onerror = () => {
|
candidate.onerror = () => {
|
||||||
worker = null;
|
worker = null;
|
||||||
for (const entry of pending.values()) {
|
for (const entry of pending.values()) {
|
||||||
entry.reject(new Error("errors.workerUnavailable"));
|
entry.reject(new ToolError("errors.workerUnavailable"));
|
||||||
}
|
}
|
||||||
pending.clear();
|
pending.clear();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ async function handle(request: WorkerRequest): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const tool = getTool(request.toolId);
|
const tool = getTool(request.toolId);
|
||||||
if (!tool?.run) {
|
if (!tool?.run) {
|
||||||
throw new Error("errors.noImageRun");
|
throw new ToolError("errors.noImageRun");
|
||||||
}
|
}
|
||||||
const source: PixelImage | undefined = request.source
|
const source: PixelImage | undefined = request.source
|
||||||
? {
|
? {
|
||||||
@@ -56,7 +56,7 @@ async function handle(request: WorkerRequest): Promise<void> {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const output = await tool.run({
|
const output = await tool.run({
|
||||||
params: sanitizeSchemaParams(tool.schema, request.params),
|
params: sanitizeSchemaParams(tool.schema, request.params, { source }),
|
||||||
source,
|
source,
|
||||||
text: request.text,
|
text: request.text,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ describe("t", () => {
|
|||||||
expect(t("home.onlyEnKey")).toBe("Only English string");
|
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("неизвестный путь возвращает сам путь", () => {
|
it("неизвестный путь возвращает сам путь", () => {
|
||||||
expect(t("no.such.key")).toBe("no.such.key");
|
expect(t("no.such.key")).toBe("no.such.key");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
applySourceDefaults,
|
||||||
defaultSchemaParams,
|
defaultSchemaParams,
|
||||||
field,
|
field,
|
||||||
sanitizeSchemaParams,
|
sanitizeSchemaParams,
|
||||||
toolSchema,
|
toolSchema,
|
||||||
|
type Dimension,
|
||||||
} from "./registry-schema";
|
} from "./registry-schema";
|
||||||
|
|
||||||
interface FrameParams {
|
interface FrameParams {
|
||||||
@@ -32,6 +34,24 @@ const frameSchema = toolSchema<FrameParams>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
interface SourceParams {
|
||||||
|
size: Dimension;
|
||||||
|
fixed: Dimension;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceSchema = toolSchema<SourceParams>({
|
||||||
|
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: дефолты из схемы", () => {
|
describe("registry-schema: дефолты из схемы", () => {
|
||||||
it("собирает дефолты всех полей", () => {
|
it("собирает дефолты всех полей", () => {
|
||||||
expect(defaultSchemaParams(frameSchema)).toEqual({
|
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)", () => {
|
describe("registry-schema: раскладка (schema.layout)", () => {
|
||||||
it("toolSchema сохраняет группы полей", () => {
|
it("toolSchema сохраняет группы полей", () => {
|
||||||
expect(frameSchema.layout).toEqual({
|
expect(frameSchema.layout).toEqual({
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ export interface DimensionSpec extends FieldSpecBase {
|
|||||||
max: number;
|
max: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
defaultFromSource?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaContext {
|
||||||
|
source?: Dimension;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */
|
/** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */
|
||||||
@@ -270,6 +275,7 @@ export const field = {
|
|||||||
max: number;
|
max: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
defaultFromSource?: boolean;
|
||||||
}): Field<Dimension> => ({
|
}): Field<Dimension> => ({
|
||||||
spec: { kind: "dimension", ...s },
|
spec: { kind: "dimension", ...s },
|
||||||
}),
|
}),
|
||||||
@@ -341,15 +347,27 @@ function clamp(value: number, min?: number, max?: number): number {
|
|||||||
return value;
|
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. */
|
/** Дефолты из схемы — единый источник значений по умолчанию для нового UI. */
|
||||||
export function defaultSchemaParams<P>(
|
export function defaultSchemaParams<P>(
|
||||||
schema: ToolSchema<P>,
|
schema: ToolSchema<P>,
|
||||||
|
context?: SchemaContext,
|
||||||
): Record<keyof P, unknown> {
|
): Record<keyof P, unknown> {
|
||||||
const out = {} as Record<keyof P, unknown>;
|
const out = {} as Record<keyof P, unknown>;
|
||||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||||
const spec = schema.fields[key].spec;
|
const spec = schema.fields[key].spec;
|
||||||
if (spec.kind === "dimension") {
|
if (spec.kind === "dimension") {
|
||||||
out[key] = { width: spec.width, height: spec.height };
|
out[key] = resolveDimensionDefaults(spec, context);
|
||||||
} else if (spec.kind === "color-pair") {
|
} else if (spec.kind === "color-pair") {
|
||||||
out[key] = { from: spec.from, to: spec.to };
|
out[key] = { from: spec.from, to: spec.to };
|
||||||
} else if (spec.kind === "colors") {
|
} else if (spec.kind === "colors") {
|
||||||
@@ -382,10 +400,27 @@ export function defaultSchemaParams<P>(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applySourceDefaults<P>(
|
||||||
|
schema: ToolSchema<P>,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
context: SchemaContext,
|
||||||
|
): Record<keyof P, unknown> {
|
||||||
|
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`. */
|
/** Валидация/нормализация значений по схеме. Аналог старого `sanitizeParams`. */
|
||||||
export function sanitizeSchemaParams<P>(
|
export function sanitizeSchemaParams<P>(
|
||||||
schema: ToolSchema<P>,
|
schema: ToolSchema<P>,
|
||||||
values: Record<string, unknown>,
|
values: Record<string, unknown>,
|
||||||
|
context?: SchemaContext,
|
||||||
): Record<keyof P, unknown> {
|
): Record<keyof P, unknown> {
|
||||||
const out = {} as Record<keyof P, unknown>;
|
const out = {} as Record<keyof P, unknown>;
|
||||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||||
@@ -425,17 +460,22 @@ export function sanitizeSchemaParams<P>(
|
|||||||
"height" in raw
|
"height" in raw
|
||||||
? (raw as Record<string, unknown>)
|
? (raw as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const defaults = resolveDimensionDefaults(spec, context);
|
||||||
const w =
|
const w =
|
||||||
typeof r?.width === "number" && Number.isFinite(r.width)
|
typeof r?.width === "number" && Number.isFinite(r.width)
|
||||||
? r.width
|
? r.width
|
||||||
: spec.width;
|
: defaults.width;
|
||||||
const h =
|
const h =
|
||||||
typeof r?.height === "number" && Number.isFinite(r.height)
|
typeof r?.height === "number" && Number.isFinite(r.height)
|
||||||
? 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] = {
|
out[key] = {
|
||||||
width: clamp(w, spec.min, spec.max),
|
width: clamp(sourceSize.width, spec.min, spec.max),
|
||||||
height: clamp(h, spec.min, spec.max),
|
height: clamp(sourceSize.height, spec.min, spec.max),
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,8 +194,9 @@ export const resizeSchema = toolSchema<ResizeParams>(
|
|||||||
label: "fields.canvasSize",
|
label: "fields.canvasSize",
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 20000,
|
max: 20000,
|
||||||
width: 0,
|
width: 1,
|
||||||
height: 0,
|
height: 1,
|
||||||
|
defaultFromSource: true,
|
||||||
}),
|
}),
|
||||||
keepAspect: field.checkbox({ label: "fields.keepAspect", default: true }),
|
keepAspect: field.checkbox({ label: "fields.keepAspect", default: true }),
|
||||||
},
|
},
|
||||||
@@ -263,10 +264,11 @@ export const cropSchema = toolSchema<CropParams>(
|
|||||||
}),
|
}),
|
||||||
size: field.dimension({
|
size: field.dimension({
|
||||||
label: "fields.cropAreaSize",
|
label: "fields.cropAreaSize",
|
||||||
min: 0,
|
min: 1,
|
||||||
max: 2000,
|
max: 2000,
|
||||||
width: 0,
|
width: 1,
|
||||||
height: 0,
|
height: 1,
|
||||||
|
defaultFromSource: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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-дефолты корректны", () => {
|
it("create-empty: dimension-дефолты корректны", () => {
|
||||||
const tool = TOOLS.find((t) => t.id === "create-empty-png")!;
|
const tool = TOOLS.find((t) => t.id === "create-empty-png")!;
|
||||||
const d = defaultSchemaParams(tool.schema);
|
const d = defaultSchemaParams(tool.schema);
|
||||||
|
|||||||
Reference in New Issue
Block a user