mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-16 14:36:35 +00:00
Compare commits
8
Commits
371387ce81
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
081ee6bae1 | ||
|
|
d24ee0a448 | ||
|
|
44cfca92ce | ||
|
|
d0857c5d45 | ||
|
|
c2f3838485 | ||
|
|
49d0a9444e | ||
|
|
5167b0a9b1 | ||
|
|
b3e2735fc3 |
+13
-1
@@ -2,13 +2,25 @@
|
|||||||
|
|
||||||
## lint и прочие правила
|
## lint и прочие правила
|
||||||
|
|
||||||
- [ ] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
||||||
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
||||||
правило `i18n/dict-consistency`, детали в §Фаза 8
|
правило `i18n/dict-consistency`, детали в §Фаза 8
|
||||||
`docs/plan-preview-i18n.md`
|
`docs/plan-preview-i18n.md`
|
||||||
|
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
|
||||||
|
что за `SonarLint` или написать кастомный линтер)
|
||||||
|
- [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n
|
||||||
|
модуль
|
||||||
|
- [ ] консистентность комментариев - если есть описание к одному инструменту -
|
||||||
|
оно должно быть у всех или объяснено, почему этот инструмент такой
|
||||||
|
особенный и заслуживает комментарий
|
||||||
|
|
||||||
## Мелочи всякие
|
## Мелочи всякие
|
||||||
|
|
||||||
|
- [ ] Пересмотреть тесты - исправить всякие хрупкие, которые проверяют
|
||||||
|
количество инструментов, точное совпадение текстов, поиск по
|
||||||
|
`img[alt="Result image"]`, svelte классам и тому подобные
|
||||||
|
- [ ] можно ли в тестах получать i18n ключ вместо текста?
|
||||||
|
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире и т.д.
|
||||||
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
|
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
|
||||||
должны быть настраиваемой 0-100%, а не переключателем
|
должны быть настраиваемой 0-100%, а не переключателем
|
||||||
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
|
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
# План: исправления по аудиту UX/UI
|
||||||
|
|
||||||
|
> Статус: **draft** — на ревью.
|
||||||
|
>
|
||||||
|
> Источник: `docs/tools-audit.md`. Этот документ разбивает аудит на конкретные
|
||||||
|
> шаги, группирует по типу работы и фиксирует решения.
|
||||||
|
|
||||||
|
## 0. Ключевое решение: select → buttons
|
||||||
|
|
||||||
|
Существующий `Segmented.svelte` — компонент с группированными кнопками (общая
|
||||||
|
рамка, разделители между сегментами). Для параметров инструментов он не
|
||||||
|
подходит: при 3+ опциях сегменты сжимаются, текст не читается.
|
||||||
|
|
||||||
|
**Решение:** новый компонент `ButtonsControl` — раздельные outline-кнопки с
|
||||||
|
отступами. Каждая опция — отдельная `<button>` со своей рамкой, активная
|
||||||
|
подсвечивается заливкой (`--color-main`). Не зависит от `Segmented`.
|
||||||
|
|
||||||
|
### Компонент `ButtonsControl`
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- web/src/lib/components/fields/schema/ButtonsControl.svelte -->
|
||||||
|
<div class="buttons" role="group">
|
||||||
|
{#each options as opt}
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
class:selected={value === opt.value}
|
||||||
|
aria-pressed={value === opt.value}
|
||||||
|
onclick={() => select(opt.value)}
|
||||||
|
>{opt.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: var(--space-m) var(--space-l);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn.selected {
|
||||||
|
background: var(--color-main);
|
||||||
|
color: var(--color-background);
|
||||||
|
border-color: var(--color-main);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Схема: `field.buttons()`
|
||||||
|
|
||||||
|
Новый тип в `registry-schema.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ButtonsSpec<V extends string = string> extends FieldSpecBase {
|
||||||
|
kind: "buttons";
|
||||||
|
default: V;
|
||||||
|
options: { value: V; label: string }[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Фабрика:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
buttons: <V extends string>(s: Omit<ButtonsSpec<V>, "kind">): Field<V> => ({
|
||||||
|
spec: { kind: "buttons", ...s },
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
Регистрация: добавить `"buttons": {} as ButtonsSpec` в `fieldSpecs`, добавить
|
||||||
|
`ButtonsControl` в `FIELDS` в `SchemaFields.svelte`, добавить дефолт/санитайз в
|
||||||
|
`sanitizeSchemaParams` и `defaultSchemaParams`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Волна A — инфраструктура (1 коммит)
|
||||||
|
|
||||||
|
### Шаг A1. Новый тип `buttons` в схеме
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
|
||||||
|
- `web/src/lib/registry-schema.ts` — `ButtonsSpec`, фабрика `field.buttons()`,
|
||||||
|
кейс в `sanitizeSchemaParams`, кейс в `defaultSchemaParams`
|
||||||
|
- `web/src/lib/components/fields/schema/ButtonsControl.svelte` — новый компонент
|
||||||
|
- `web/src/lib/components/SchemaFields.svelte` — импорт + запись в `FIELDS`
|
||||||
|
|
||||||
|
### Шаг A2. Seed → input + randomize
|
||||||
|
|
||||||
|
Для `field.number()` с `kind: "seed"` (или новый подтип `field.seed()`) —
|
||||||
|
рендерить поле ввода + кнопку «Random» вместо range slider.
|
||||||
|
|
||||||
|
Решение: новый вид `field.seed()` (наследует от number, но рендерится иначе).
|
||||||
|
Или проще: опциональное поле `variant?: "seed"` в `NumberSpec` — тогда
|
||||||
|
`RangeControl` покажет input + кнопку.
|
||||||
|
|
||||||
|
Решение: **`variant: "seed"` в `NumberSpec`** — меньше новых типов.
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
|
||||||
|
- `web/src/lib/registry-schema.ts` — `NumberSpec.variant?: "seed"`
|
||||||
|
- `web/src/lib/components/fields/schema/RangeControl.svelte` — условный рендер
|
||||||
|
- `web/src/lib/registry/filters.ts` — `randomizePixels`, `addNoise`: поле seed →
|
||||||
|
`{ ...field.number(...), spec: { ...spec, variant: "seed" } }` (или
|
||||||
|
пересоздать через `field.seed()`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Волна B — select → buttons (по файлам)
|
||||||
|
|
||||||
|
Каждый шаг — один коммит, < 500 строк. Меняем `field.select()` →
|
||||||
|
`field.buttons()` в перечисленных ниже схемах.
|
||||||
|
|
||||||
|
### B1. geometry.ts (8 инструментов)
|
||||||
|
|
||||||
|
| Инструмент | Поле | Опции |
|
||||||
|
| ----------------------- | -------- | ----------------------------------------- |
|
||||||
|
| rotate-png | angle | 90° / 180° / 270° |
|
||||||
|
| flip-png | axis | horizontal / vertical |
|
||||||
|
| swap-orientation-png | target | portrait / landscape |
|
||||||
|
| symmetric-copy-png | axis | vertical / horizontal |
|
||||||
|
| symmetric-copy-png | keepSide | left / right / top / bottom |
|
||||||
|
| change-aspect-ratio-png | ratio | 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 16:9 / 9:16 |
|
||||||
|
| change-aspect-ratio-png | mode | crop / pad |
|
||||||
|
|
||||||
|
**Исключение:** `change-canvas-size-png` (anchor, 9 опций) — оставить
|
||||||
|
`field.select()`, т.к. 9 кнопок в ряд не поместятся. Аналогично `position9` (уже
|
||||||
|
отдельный компонент `PositionControl`).
|
||||||
|
|
||||||
|
### B2. color.ts (4 инструмента)
|
||||||
|
|
||||||
|
| Инструмент | Поле | Опции |
|
||||||
|
| ------------------------ | --------- | ------------------------------------ |
|
||||||
|
| dithering-png | pattern | floyd-steinberg / bayer |
|
||||||
|
| extract-channel-png | channel | red / green / blue |
|
||||||
|
| swap-channels-png | pair | r-g / r-b / g-b |
|
||||||
|
| decrease-color-count-png | maxColors | 2 / 4 / 8 / 16 / 32 / 64 / 128 / 256 |
|
||||||
|
|
||||||
|
**Исключение:** channel spaces (hsl/hsv/... component) — 3-6 опций, но это
|
||||||
|
динамические инструменты, оставить `select`.
|
||||||
|
|
||||||
|
### B3. analyze.ts (5 инструментов)
|
||||||
|
|
||||||
|
| Инструмент | Поле | Опции |
|
||||||
|
| ------------------------- | ---- | ------------------ |
|
||||||
|
| show-transparent-png | mode | binary / highlight |
|
||||||
|
| show-grayscale-pixels-png | mode | (тот же) |
|
||||||
|
| show-color-pixels-png | mode | (тот же) |
|
||||||
|
| light-pixel-mask-png | mode | (тот же) |
|
||||||
|
| dark-pixel-mask-png | mode | (тот же) |
|
||||||
|
|
||||||
|
Все используют общий `maskBaseFields` — правка в одном месте.
|
||||||
|
|
||||||
|
### B4. filters.ts (1 инструмент)
|
||||||
|
|
||||||
|
| Инструмент | Поле | Опции |
|
||||||
|
| ------------- | ---- | ------------ |
|
||||||
|
| add-noise-png | mode | mono / color |
|
||||||
|
|
||||||
|
### B5. generate.ts (12 инструментов)
|
||||||
|
|
||||||
|
| Инструмент | Поле | Опции |
|
||||||
|
| ------------------ | ------------ | --------------------- |
|
||||||
|
| color-spectrum-png | direction | horizontal / vertical |
|
||||||
|
| step-colors-png | layout | grid / strip |
|
||||||
|
| complementary-png | layout | grid / strip |
|
||||||
|
| triadic-png | layout | grid / strip |
|
||||||
|
| tetradic-png | layout | grid / strip |
|
||||||
|
| analogous-png | layout | grid / strip |
|
||||||
|
| monochromatic-png | layout | grid / strip |
|
||||||
|
| shades-png | layout | grid / strip |
|
||||||
|
| sort-colors-png | layout | grid / strip |
|
||||||
|
| mix-colors-png | (нет select) | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Волна C — обязательные фиксы (баги + дефолты)
|
||||||
|
|
||||||
|
### C1. resize-png: дефолт 0×0 → осмысленный
|
||||||
|
|
||||||
|
`geometry.ts`: `width: 0, height: 0` → `width: 512, height: 512` (или
|
||||||
|
`width: imgWidth, height: imgHeight` — но дефолт в схеме не может знать размер
|
||||||
|
изображения). **Решение:** дефолт `512×512`.
|
||||||
|
|
||||||
|
### C2. symmetric-copy-png: "keep side" не работает
|
||||||
|
|
||||||
|
Баг в `run()`: `keepSide` не учитывает `axis`. При `axis: "vertical"` (удвоение
|
||||||
|
ширины) работают только `left`/`right`, `top`/`bottom` не имеют смысла. Нужно:
|
||||||
|
либо фильтровать опции `keepSide` в зависимости от `axis` (reactive schema),
|
||||||
|
либо нормализовать в `run()`.
|
||||||
|
|
||||||
|
**Решение:** reactive — при смене `axis` сбрасывать `keepSide` на допустимое
|
||||||
|
значение. Пока достаточно в `run()`: если `axis === "vertical"` и `keepSide` ∈
|
||||||
|
{top, bottom} → заменить на `left`.
|
||||||
|
|
||||||
|
### C3. Механизм проброса размеров картинки в схему
|
||||||
|
|
||||||
|
**Проблема.** Поля, значения которых зависят от размеров исходника (crop x/y,
|
||||||
|
resize, offsets), объявлены в статической схеме (`-100000…100000`), а реальная
|
||||||
|
картинка обычно 800–4000px. Диапазон надо считать от фактического размера
|
||||||
|
изображения, а не от потолков.
|
||||||
|
|
||||||
|
**Что нужно спроектировать и завести:**
|
||||||
|
|
||||||
|
1. **Где берём размер.** `SchemaToolView` уже знает размер после `decodeFile()`
|
||||||
|
→ `PixelImage` (width/height). Сейчас он никуда не пробрасывается — надо
|
||||||
|
прокинуть в `SchemaFields` и дальше в контролы, например через context или
|
||||||
|
проп `sourceSize`.
|
||||||
|
|
||||||
|
2. **Как схеме описать зависимость.** В спеках добавить ссылку на размер
|
||||||
|
источника вместо жёстких чисел. Кандидат:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
x: field.slider({
|
||||||
|
label: "fields.x",
|
||||||
|
bound: "sourceWidth", // min/max = ±sourceWidth
|
||||||
|
default: 0,
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
`BoundSpec = "sourceWidth" | "sourceHeight" | "minSide" | "maxSide"`,
|
||||||
|
применимо к `NumberSpec`, `SliderSpec`, `OffsetSpec`, `DimensionSpec`.
|
||||||
|
|
||||||
|
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 остаются как есть.
|
||||||
|
|
||||||
|
### C4. verify-is-png: неверное название
|
||||||
|
|
||||||
|
Проверяет текст, не PNG-файл. Переименовать:
|
||||||
|
|
||||||
|
- `id` → `verify-is-png-data` (или оставить для обратной совместимости)
|
||||||
|
- `title` → "Verify PNG data"
|
||||||
|
- `description` → уточнить
|
||||||
|
|
||||||
|
**Решение:** поменять title и description, id не трогать (url-dependent).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Волна D — обязательные фиксы (новые параметры)
|
||||||
|
|
||||||
|
### D1. remove-alpha-channel-png: выбор цвета фона
|
||||||
|
|
||||||
|
Сейчас захардкожен `#ffffff`. Добавить `field.color({ default: "#ffffff" })`.
|
||||||
|
|
||||||
|
Файл: `alpha.ts`, схема `removeAlphaChannelSchema`.
|
||||||
|
|
||||||
|
### D2. extract-alpha-mask-png: галочка «инвертировать»
|
||||||
|
|
||||||
|
Добавить `field.checkbox({ label: "fields.invertMask", default: false })`. В
|
||||||
|
`run()`: если `invert`, вызвать `invertAlpha()` после `extractAlphaMask()`.
|
||||||
|
|
||||||
|
Файл: `alpha.ts`.
|
||||||
|
|
||||||
|
### D3. change-canvas-size-png: position9 вместо select
|
||||||
|
|
||||||
|
`anchor` сейчас `field.select()` с 9 опциями → `field.position9()`.
|
||||||
|
|
||||||
|
Файл: `geometry.ts`. Уже есть `PositionControl`.
|
||||||
|
|
||||||
|
### D4. Generators: добавить height
|
||||||
|
|
||||||
|
Инструменты без `size`/`height`:
|
||||||
|
|
||||||
|
- blend-two-png — добавить
|
||||||
|
`height: field.slider({ min: 1, max: 1024, default: 512 })`
|
||||||
|
- step-colors-png — аналогично
|
||||||
|
- complementary, triadic, tetradic, analogous, monochromatic, shades,
|
||||||
|
sort-colors — все используют `paletteBaseSchema` (общий объект), добавить
|
||||||
|
`height` туда
|
||||||
|
- mix-colors-png — добавить height
|
||||||
|
|
||||||
|
Файл: `generate.ts`.
|
||||||
|
|
||||||
|
### D5. add-border-png: прозрачность цвета
|
||||||
|
|
||||||
|
Текущий `field.color()` не поддерживает alpha. Пока что: оставить как есть
|
||||||
|
(прозрачность не поддерживается нативным color picker). Отметить в аудите как
|
||||||
|
blocked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Волна E — полировка (некритично)
|
||||||
|
|
||||||
|
### E1. Плейсхолдер "нет параметров"
|
||||||
|
|
||||||
|
Инструменты с пустой схемой (`EmptyParams`) выглядят странно. Добавить
|
||||||
|
`EmptyState` или подсказку "No parameters — click Run".
|
||||||
|
|
||||||
|
Файл: `SchemaToolView.svelte` — показать подсказку если `schema.fields` пуст.
|
||||||
|
|
||||||
|
### E2. quantize-png: пресеты
|
||||||
|
|
||||||
|
`field.slider` с max 64 → добавить пресеты (8, 16, 32, 64) как кнопки под
|
||||||
|
слайдером. Либо `field.buttons()` с 4 опциями вместо слайдера.
|
||||||
|
|
||||||
|
### E3. trim-empty-space-png: 0-254 → проценты
|
||||||
|
|
||||||
|
Текущий `min: 0, max: 254` — нестандартно. Добавить переключатель px/% (аналог
|
||||||
|
шага A1 — `variant: "alpha-threshold"` в NumberSpec).
|
||||||
|
|
||||||
|
### E4. add-text-png: plate offset
|
||||||
|
|
||||||
|
Plate снизу имеет большой отступ — добавить галочку "compact plate" или
|
||||||
|
уменьшить дефолтный padding.
|
||||||
|
|
||||||
|
### E5. emoji-to-png: выбор эмодзи
|
||||||
|
|
||||||
|
Поле `text` → добавить группу часто используемых эмодзи как кнопки- пресеты над
|
||||||
|
полем ввода.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Верификация
|
||||||
|
|
||||||
|
После каждой волны:
|
||||||
|
|
||||||
|
1. `pnpm --dir web build` — без ошибок
|
||||||
|
2. `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json` — без ошибок
|
||||||
|
3. `pnpm --dir web lint` — без ошибок (кроме ожидаемых долгов)
|
||||||
|
4. Ручная проверка в браузере: параметры рендерятся, инструменты работают
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Не покрыто / требует решения
|
||||||
|
|
||||||
|
Эти пункты из аудита не попали в волны A–E. Требуют либо дополнительного
|
||||||
|
исследования, либо нормативного решения, либо отложены.
|
||||||
|
|
||||||
|
### 7.1 Требуют normative решения
|
||||||
|
|
||||||
|
- **Pixel ↔ Percent** (circle-mask, square-mask, star-mask, wavy-mask — %→px,
|
||||||
|
round-corners — px→%): нужен компонент переключателя единиц в слайдере.
|
||||||
|
Отложен до волны E, т.к. требует правки `RangeControl` + всех схем.
|
||||||
|
- **Smoothing checkbox** для масок (circle, square, star, wavy, round-corners,
|
||||||
|
invert-alpha): нужен `field.checkbox("smoothing")` + правка core (shapes.ts,
|
||||||
|
alpha). Отложен — требует визуального тестирования.
|
||||||
|
- **Vignette**: выбор центра + цвет + feather — 3 новых параметра. Требует
|
||||||
|
normative решения по UI.
|
||||||
|
- **Remove-background**: выбор точки удаления — либо параметр, либо отдельный
|
||||||
|
инструмент.
|
||||||
|
- **Precision/quality**: select в resize (bilinear/bicubic/nearest) — нужен ли
|
||||||
|
выбор алгоритма?
|
||||||
|
- **Placeholders**: "нет параметров" — какой текст/компонент показывать.
|
||||||
|
|
||||||
|
### 7.2 Операционные (тест-кейсы, описания)
|
||||||
|
|
||||||
|
- find-contour-png: расширять на толщину линии (требует правки core)
|
||||||
|
- make-thicker-png: расширять картинку на толщину линии
|
||||||
|
- make-thinner-png: уменьшать от краев, fix выступа
|
||||||
|
- feather-edges-png: расширять картинку на толщину линии
|
||||||
|
- clean-edges-png: найти тест-кейс, возможно увеличить максимум
|
||||||
|
- despeckle-alpha-png: найти более заметный тест-кейс
|
||||||
|
- close-holes-png: проверить поведение на больших полупрозрачных областях
|
||||||
|
- auto-contrast-png: найти тестовый кейс
|
||||||
|
- sharpen-png: добавить тест-кейс
|
||||||
|
- center-by-alpha-png: найти тест-кейс
|
||||||
|
- png-is-grayscale: улучшить видимость результата
|
||||||
|
|
||||||
|
### 7.3 Дизайн-вопросы
|
||||||
|
|
||||||
|
- add-padding-png vs add-border-png: разница неочевидна, нужен review
|
||||||
|
- change-aspect-ratio-png: не видно что меняется — рамка результата
|
||||||
|
- watermark-tile-png: заполняет не полностью
|
||||||
|
- sepia-png: нужна ли настройка силы эффекта?
|
||||||
|
- posterize-png: настройка для каждого канала отдельно?
|
||||||
|
- pixelate-png: разные алгоритмы?
|
||||||
|
- swap-channels-png: обдумать flow через более простые инструменты
|
||||||
|
|
||||||
|
### 7.4 Аудит полей (компоненты)
|
||||||
|
|
||||||
|
- Range slider: поле ввода + кнопки +/- + кнопка сброса
|
||||||
|
- Color picker: пипетка с картинки, прозрачность
|
||||||
|
- Width/height с keep-aspect-ratio: единый компонент
|
||||||
|
- Position offset: отдельные слайдеры x/y (уже сделано в `OffsetControl`)
|
||||||
@@ -245,7 +245,7 @@
|
|||||||
|
|
||||||
### Фаза 6: Тесты
|
### Фаза 6: Тесты
|
||||||
|
|
||||||
- **6.1** `new-tools-i18n.test.ts` — полнота словарей для нового registry:
|
- **6.1** `tools-i18n.test.ts` — полнота словарей для текущего registry:
|
||||||
title/description ru для всех инструментов, каждый `label`-ключ схемы есть в
|
title/description ru для всех инструментов, каждый `label`-ключ схемы есть в
|
||||||
`fields`, каждый `groups`-ключ есть в `groups`, без лишних ключей.
|
`fields`, каждый `groups`-ключ есть в `groups`, без лишних ключей.
|
||||||
- **6.2** Фикс `known-issues.spec.ts` test.fixme — ошибки локализуются.
|
- **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 { expect, test } from "playwright/test";
|
||||||
import { expectNoErrors, trackErrors } from "./helpers/page";
|
import { expectNoErrors, trackErrors } from "./helpers/page";
|
||||||
|
|
||||||
const TOTAL = 121;
|
const TOTAL = 122;
|
||||||
const GROUPS = 8;
|
const GROUPS = 8;
|
||||||
|
|
||||||
test("catalog shows total and all groups", async ({ page }) => {
|
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 page.getByRole("button", { name: "CONVERT" }).click();
|
||||||
await expect(page.locator(".catalog-group")).toHaveCount(1);
|
await expect(page.locator(".catalog-group")).toHaveCount(1);
|
||||||
}).toPass();
|
}).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();
|
const convertCards = await page.locator(".tool-card").count();
|
||||||
expect(convertCards).toBeGreaterThan(0);
|
expect(convertCards).toBeGreaterThan(0);
|
||||||
expect(convertCards).toBeLessThan(allCount);
|
expect(convertCards).toBeLessThan(allCount);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test.describe("generators — known UI gap (#checklist, п.1)", () => {
|
|||||||
const generate = page.getByRole("button", { name: "Generate" });
|
const generate = page.getByRole("button", { name: "Generate" });
|
||||||
await expect(generate).toBeVisible();
|
await expect(generate).toBeVisible();
|
||||||
await generate.click();
|
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 }) => {
|
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 { opaquePng } from "./helpers/fixtures";
|
||||||
import { openTool, uploadImage } from "./helpers/page";
|
import { openTool, uploadImage } from "./helpers/page";
|
||||||
|
|
||||||
// FIXME: Весь файл — зарегистрированные баги preview (см. docs/checklist-manual-testing.md
|
// FIXME: В файле зарегистрированы баги preview (см. docs/checklist-manual-testing.md
|
||||||
// → «Известные баги preview»). Тела assert'ят ОЖИДАЕМОЕ поведение. Статус fixme
|
// → «Известные баги preview»). Тела assert'ят ОЖИДАЕМОЕ поведение. Статус fixme
|
||||||
// означает «мы знаем, что сейчас падает»; когда баг починят — убрать fixme и
|
// означает «мы знаем, что сейчас падает»; когда баг починят — убрать fixme и
|
||||||
// тест станет зелёным «сам по себе».
|
// тест станет зелёным «сам по себе». (Тест про локализацию ошибок переехал в
|
||||||
|
// i18n.spec.ts — он больше не issue.)
|
||||||
|
|
||||||
test.describe("known bugs — documented as fixme", () => {
|
test.describe("known bugs — documented as fixme", () => {
|
||||||
test.fixme("resize-png: upload produces a resized result (no alert)", async ({
|
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 openTool(page, "resize-png");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
await expect(page.locator("[role='alert']")).toHaveCount(0);
|
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 ({
|
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 openTool(page, "crop-png");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
await expect(page.locator("[role='alert']")).toHaveCount(0);
|
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("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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ test("unknown tool route responds 404 on static build", async ({ page }) => {
|
|||||||
// 36 | expect(["light", "dark"]).toContain(before);
|
// 36 | expect(["light", "dark"]).toContain(before);
|
||||||
// 37 | expect(["light", "dark"]).toContain(after);
|
// 37 | expect(["light", "dark"]).toContain(after);
|
||||||
// > 38 | expect(stored).toBe(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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|||||||
+24
-22
@@ -1,29 +1,29 @@
|
|||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
import {
|
import {
|
||||||
opaquePng,
|
|
||||||
landscapePng,
|
|
||||||
onePixelPng,
|
|
||||||
largePng,
|
|
||||||
corruptPng,
|
corruptPng,
|
||||||
|
landscapePng,
|
||||||
|
largePng,
|
||||||
|
onePixelPng,
|
||||||
|
opaquePng,
|
||||||
} from "./helpers/fixtures";
|
} from "./helpers/fixtures";
|
||||||
import {
|
import {
|
||||||
trackErrors,
|
|
||||||
openTool,
|
|
||||||
uploadImage,
|
|
||||||
metaValue,
|
|
||||||
suggestedDownloadName,
|
|
||||||
expectNoErrorAlert,
|
expectNoErrorAlert,
|
||||||
expectNoErrors,
|
expectNoErrors,
|
||||||
|
metaValue,
|
||||||
|
openTool,
|
||||||
|
suggestedDownloadName,
|
||||||
|
trackErrors,
|
||||||
|
uploadImage,
|
||||||
} from "./helpers/page";
|
} from "./helpers/page";
|
||||||
|
|
||||||
test("flip-png: full flow — upload, result, meta, download", async ({
|
test("flip-png: full flow - upload, result, meta, download", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "flip-png");
|
await openTool(page, "flip-png");
|
||||||
await uploadImage(page, landscapePng);
|
await uploadImage(page, landscapePng);
|
||||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||||
await expect(page.locator('img[alt="result"]')).toHaveAttribute(
|
await expect(page.locator('img[alt="Result image"]')).toHaveAttribute(
|
||||||
"src",
|
"src",
|
||||||
/^(blob:|data:image\/png;base64,)/,
|
/^(blob:|data:image\/png;base64,)/,
|
||||||
);
|
);
|
||||||
@@ -42,7 +42,7 @@ test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
|
|||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "convert-png-to-jpg");
|
await openTool(page, "convert-png-to-jpg");
|
||||||
await uploadImage(page, opaquePng);
|
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(
|
const downloadName = await suggestedDownloadName(
|
||||||
page,
|
page,
|
||||||
'button[aria-label="Download result"]',
|
'button[aria-label="Download result"]',
|
||||||
@@ -52,12 +52,12 @@ test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
|
|||||||
expectNoErrors(sink);
|
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 openTool(page, "flip-png");
|
||||||
await uploadImage(page, opaquePng);
|
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 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(".empty")).toContainText("no result yet");
|
||||||
await expect(page.locator('img[alt="source"]')).toBeVisible();
|
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 }) => {
|
test("blur: changing slider updates result image", async ({ page }) => {
|
||||||
await openTool(page, "blur-png");
|
await openTool(page, "blur-png");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||||
const src1 = await page.locator('img[alt="result"]').getAttribute("src");
|
const src1 = await page
|
||||||
|
.locator('img[alt="Result image"]')
|
||||||
|
.getAttribute("src");
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
await page.locator('input[type="range"]').fill("20");
|
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",
|
"src",
|
||||||
src1 ?? "",
|
src1 ?? "",
|
||||||
);
|
);
|
||||||
@@ -88,7 +90,7 @@ test("runs without Web Worker (fallback)", async ({ browser }) => {
|
|||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "flip-png");
|
await openTool(page, "flip-png");
|
||||||
await uploadImage(page, landscapePng);
|
await uploadImage(page, landscapePng);
|
||||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||||
await expectNoErrorAlert(page);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
await context.close();
|
await context.close();
|
||||||
@@ -99,7 +101,7 @@ test("invalid file upload shows error, no crash", async ({ page }) => {
|
|||||||
await openTool(page, "flip-png");
|
await openTool(page, "flip-png");
|
||||||
await uploadImage(page, corruptPng);
|
await uploadImage(page, corruptPng);
|
||||||
await expect(page.locator('[role="alert"]')).toBeVisible();
|
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);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ for (const fixture of [onePixelPng, largePng]) {
|
|||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "flip-png");
|
await openTool(page, "flip-png");
|
||||||
await uploadImage(page, fixture);
|
await uploadImage(page, fixture);
|
||||||
await expect(page.locator('img[alt="result"]')).toBeVisible();
|
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
import {
|
import {
|
||||||
opaquePng,
|
opaquePng,
|
||||||
transparentPng,
|
|
||||||
tinyBase64,
|
|
||||||
svgMarkup,
|
|
||||||
pixelRow,
|
pixelRow,
|
||||||
|
svgMarkup,
|
||||||
|
tinyBase64,
|
||||||
|
transparentPng,
|
||||||
} from "./helpers/fixtures";
|
} from "./helpers/fixtures";
|
||||||
import {
|
import {
|
||||||
trackErrors,
|
|
||||||
openTool,
|
|
||||||
uploadImage,
|
|
||||||
expectNoErrorAlert,
|
expectNoErrorAlert,
|
||||||
expectNoErrors,
|
expectNoErrors,
|
||||||
|
openTool,
|
||||||
|
trackErrors,
|
||||||
|
uploadImage,
|
||||||
} from "./helpers/page";
|
} from "./helpers/page";
|
||||||
|
|
||||||
for (const [id, input] of [
|
for (const [id, input] of [
|
||||||
@@ -30,7 +30,7 @@ for (const [id, input] of [
|
|||||||
await openTool(page, id);
|
await openTool(page, id);
|
||||||
await page.locator(".text-source textarea").fill(input);
|
await page.locator(".text-source textarea").fill(input);
|
||||||
await page.getByRole("button", { name: "Render text" }).click();
|
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 });
|
}).toPass({ timeout: 25_000 });
|
||||||
await expectNoErrorAlert(page);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
@@ -50,8 +50,8 @@ test("png-to-base64 shows decoded text result", async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const [input, expected] of [
|
for (const [input, expected] of [
|
||||||
[tinyBase64, "Yes — valid PNG signature."],
|
[tinyBase64, "Yes — this is a valid PNG signature."],
|
||||||
["aGVsbG8=", "No — the content is not a PNG."],
|
["aGVsbG8=", "No — the signature does not match a PNG file."],
|
||||||
] as const) {
|
] as const) {
|
||||||
test(`verify-is-png verdict: ${expected}`, async ({ page }) => {
|
test(`verify-is-png verdict: ${expected}`, async ({ page }) => {
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
@@ -67,7 +67,7 @@ test("png-is-transparent: opaque image → 'No'", async ({ page }) => {
|
|||||||
await openTool(page, "png-is-transparent");
|
await openTool(page, "png-is-transparent");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
await expect(page.locator(".verdict-text")).toContainText(
|
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 openTool(page, "png-is-transparent");
|
||||||
await uploadImage(page, transparentPng);
|
await uploadImage(page, transparentPng);
|
||||||
await expect(page.locator(".verdict-text")).toContainText(
|
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 openTool(page, "png-is-grayscale");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
await expect(page.locator(".verdict-text")).toContainText(
|
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 { expect, test } from "playwright/test";
|
||||||
import { opaquePng, transparentPng } from "./helpers/fixtures";
|
|
||||||
import type { SourceFile } from "./helpers/fixtures";
|
import type { SourceFile } from "./helpers/fixtures";
|
||||||
|
import { opaquePng, transparentPng } from "./helpers/fixtures";
|
||||||
import {
|
import {
|
||||||
trackErrors,
|
|
||||||
openTool,
|
|
||||||
uploadImage,
|
|
||||||
expectNoErrorAlert,
|
expectNoErrorAlert,
|
||||||
expectNoErrors,
|
expectNoErrors,
|
||||||
|
openTool,
|
||||||
|
trackErrors,
|
||||||
|
uploadImage,
|
||||||
} from "./helpers/page";
|
} from "./helpers/page";
|
||||||
|
|
||||||
type Kind = "image" | "text-out" | "verdict";
|
type Kind = "image" | "text-out" | "verdict";
|
||||||
@@ -101,7 +101,7 @@ test.describe("smoke: tools produce output without errors", () => {
|
|||||||
const outputLocator = (): ReturnType<typeof page.locator> => {
|
const outputLocator = (): ReturnType<typeof page.locator> => {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "image":
|
case "image":
|
||||||
return page.locator('img[alt="result"]');
|
return page.locator('img[alt="Result image"]');
|
||||||
case "text-out":
|
case "text-out":
|
||||||
return page.locator(".result-pre code");
|
return page.locator(".result-pre code");
|
||||||
case "verdict":
|
case "verdict":
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
- `web/eslint-plugins/` — локальные ESLint-плагины.
|
- `web/eslint-plugins/` — локальные ESLint-плагины.
|
||||||
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
|
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
|
||||||
- `isolation/` — правило изоляции old/new UI.
|
- `isolation/` — правило изоляции old/new UI.
|
||||||
|
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
|
||||||
|
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
|
||||||
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
|
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
|
||||||
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
|
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
|
||||||
токенов) и файлы для изоляционных тестов.
|
токенов), файлы для изоляционных тестов и мини-словари `lib/i18n/`.
|
||||||
- `web/scripts/`:
|
- `web/scripts/`:
|
||||||
- `lint-all.mjs` — оркестратор `lint:all`.
|
- `lint-all.mjs` — оркестратор `lint:all`.
|
||||||
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
|
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
|
||||||
@@ -62,6 +64,35 @@
|
|||||||
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
|
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
|
||||||
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
|
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
|
||||||
|
|
||||||
|
## Правило плагина i18n (`i18n/dict-consistency`)
|
||||||
|
|
||||||
|
Кросс-языковой линтер словарей `lib/i18n/` (план: `docs/plan-preview-i18n.md`
|
||||||
|
§Фаза 8). Сравнивает каждый словарь со **всеми** остальными локалями (не только
|
||||||
|
с BASE). Список локалей берётся из `LOCALES` в `lib/i18n/dict.ts` — новый
|
||||||
|
словарь подхватывается без правки правила.
|
||||||
|
|
||||||
|
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
|
||||||
|
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
|
||||||
|
расхождения:
|
||||||
|
- **паритет ключей**: правило собирает union всех ключей из словарей-соседей
|
||||||
|
(с диска) и для текущего файла репортит те, которых в нём нет — ровно одно
|
||||||
|
предупреждение на разрыв, независимо от числа локалей, где ключ есть;
|
||||||
|
пропавшая целая секция репортится один раз (не по каждому потомку);
|
||||||
|
- **пустые значения**: `""` и строка из одних пробелов;
|
||||||
|
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
|
||||||
|
каноническим (мажоритарный по локалям, при равенстве — первый по `LOCALES`)
|
||||||
|
— ловит потерянную при переводе переменную ровно один раз, даже когда
|
||||||
|
отклоняется одна-единственная локаль.
|
||||||
|
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
|
||||||
|
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
|
||||||
|
(синтакс, без типов).
|
||||||
|
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
|
||||||
|
(осознанные отклонения до достижения zero-warn).
|
||||||
|
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
|
||||||
|
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
|
||||||
|
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
||||||
|
диске.
|
||||||
|
|
||||||
## Токены-префиксы (целевой словарь дизайна)
|
## Токены-префиксы (целевой словарь дизайна)
|
||||||
|
|
||||||
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
|
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Dict } from "./dict";
|
||||||
|
|
||||||
|
export const de: Dict = {
|
||||||
|
header: {
|
||||||
|
workspace: "Arbeitsbereich",
|
||||||
|
catalog: "Katalog",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
hero: "Hallo {name}!",
|
||||||
|
restoreLast: "{title} wiederherstellen",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sourceRequired: "Quelle erforderlich",
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
addBorder: {
|
||||||
|
title: "Rahmen hinzufügen",
|
||||||
|
description: "Zeichnet einen {kind}-Rahmen.",
|
||||||
|
results: {
|
||||||
|
done: "Rahmen {what}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export const LOCALES = ["en", "ru", "de"] as const;
|
||||||
|
|
||||||
|
export const BASE_LOCALE: "en" = "en";
|
||||||
|
|
||||||
|
export type Dict = {
|
||||||
|
header: Record<string, string>;
|
||||||
|
home: Record<string, string>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
tools: Record<string, ToolStrings>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolStrings = {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
results?: Record<string, string>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Dict } from "./dict";
|
||||||
|
|
||||||
|
export const en: Dict = {
|
||||||
|
header: {
|
||||||
|
workspace: "Workspace",
|
||||||
|
catalog: "Catalog",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
hero: "Hello {name}!",
|
||||||
|
restoreLast: "Restore {title}",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sourceRequired: "Source image required",
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
addBorder: {
|
||||||
|
title: "Add border",
|
||||||
|
description: "Draws a {kind} frame.",
|
||||||
|
results: {
|
||||||
|
done: "Border {what}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Dict } from "./dict";
|
||||||
|
|
||||||
|
export const ru: Dict = {
|
||||||
|
header: {
|
||||||
|
workspace: "Рабочая область",
|
||||||
|
catalog: "Каталог",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
hero: "Привет, {name}!",
|
||||||
|
restoreLast: "Вернуть {title}",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
sourceRequired: "Нужен исходник",
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
addBorder: {
|
||||||
|
title: "Добавить рамку",
|
||||||
|
description: "Рисует рамку {kind}.",
|
||||||
|
results: {
|
||||||
|
done: "Рамка {what}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Tests for i18n/dict-consistency (web/eslint-plugins/i18n/dict-consistency.js).
|
||||||
|
//
|
||||||
|
// The rule reads its own dict dir from disk (LOCALES in dict.ts + sibling
|
||||||
|
// locale files), so it runs through the Linter API with cwd pinned to the
|
||||||
|
// fixture tree (see __tests__/helpers.ts). The committed fixtures at
|
||||||
|
// __fixtures__/src/lib/i18n/ are mutually consistent (zero-warn) — tests feed
|
||||||
|
// MODIFIED variants of en.ts / ru.ts as the linted code so each check is
|
||||||
|
// exercised against the on-disk references.
|
||||||
|
//
|
||||||
|
// Reporting contract: each file reports only its OWN gaps. A key missing in a
|
||||||
|
// file produces exactly ONE `missingKey` warning, emitted on the incomplete
|
||||||
|
// file itself (the union of all keys comes from the on-disk dicts), regardless
|
||||||
|
// of how many other locales have the key. Placeholder parity is compared
|
||||||
|
// against the canonical set — the one shared by the most locales (ties by
|
||||||
|
// LOCALES order) — so a deviation is reported exactly once.
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import dictConsistency from "../i18n/dict-consistency.js";
|
||||||
|
import {
|
||||||
|
asRuleModule,
|
||||||
|
FIXTURES_SRC,
|
||||||
|
verifyInFixtures,
|
||||||
|
type FlatConfig,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
|
const I18N_DIR = path.join(FIXTURES_SRC, "lib", "i18n");
|
||||||
|
|
||||||
|
const fixture = (name: string) =>
|
||||||
|
fs.readFileSync(path.join(I18N_DIR, name), "utf8");
|
||||||
|
|
||||||
|
function lint(code: string, relFile: string, options?: unknown) {
|
||||||
|
const rule = options ? ["warn", options] : "warn";
|
||||||
|
const config: FlatConfig = [
|
||||||
|
{ files: ["**/*.ts"], languageOptions: { parser: tseslint.parser } },
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
plugins: {
|
||||||
|
i18n: {
|
||||||
|
rules: { "dict-consistency": asRuleModule(dictConsistency) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: { "i18n/dict-consistency": rule as "warn" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return verifyInFixtures(config, code, relFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageIds(messages: { messageId?: string }[]) {
|
||||||
|
return messages.map((m) => m.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("i18n/dict-consistency", () => {
|
||||||
|
it("stays clean on the consistent fixture dicts", () => {
|
||||||
|
expect(messageIds(lint(fixture("en.ts"), "lib/i18n/en.ts"))).toEqual([]);
|
||||||
|
expect(messageIds(lint(fixture("ru.ts"), "lib/i18n/ru.ts"))).toEqual([]);
|
||||||
|
expect(messageIds(lint(fixture("de.ts"), "lib/i18n/de.ts"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a leaf key missing in the current file", () => {
|
||||||
|
const enMissing = fixture("en.ts").replace(
|
||||||
|
'\t\trestoreLast: "Restore {title}",',
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const messages = lint(enMissing, "lib/i18n/en.ts");
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messageIds(messages)).toEqual(["missingKey"]);
|
||||||
|
expect(messages[0].message).toContain('"home.restoreLast"');
|
||||||
|
expect(messages[0].message).toContain(" in en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing whole section once, not per child key", () => {
|
||||||
|
const enNoHeader = fixture("en.ts").replace(/\theader: \{[\s\S]*?\},/, "");
|
||||||
|
const messages = lint(enNoHeader, "lib/i18n/en.ts");
|
||||||
|
expect(messageIds(messages)).toEqual(["missingKey"]);
|
||||||
|
expect(messages[0].message).toContain('"header"');
|
||||||
|
expect(messages[0].message).not.toContain("header.workspace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits ONE warning per gap even when several locales own the key", () => {
|
||||||
|
const enMissing = fixture("en.ts").replace(
|
||||||
|
'\t\tsourceRequired: "Source image required",',
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const messages = lint(enMissing, "lib/i18n/en.ts");
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messageIds(messages)).toEqual(["missingKey"]);
|
||||||
|
expect(messages[0].message).toContain('"errors.sourceRequired"');
|
||||||
|
expect(messages[0].message).toContain(" in en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags empty and whitespace-only values", () => {
|
||||||
|
const enEmpty = fixture("en.ts")
|
||||||
|
.replace('workspace: "Workspace",', 'workspace: "",')
|
||||||
|
.replace('catalog: "Catalog",', 'catalog: " ",');
|
||||||
|
const messages = lint(enEmpty, "lib/i18n/en.ts");
|
||||||
|
expect(messageIds(messages)).toEqual(["emptyValue", "emptyValue"]);
|
||||||
|
expect(messages[0].message).toContain('"header.workspace"');
|
||||||
|
expect(messages[0].message).toContain(" in en");
|
||||||
|
expect(messages[1].message).toContain('"header.catalog"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a placeholder lost in translation (vs the canonical set)", () => {
|
||||||
|
const ruLostPlaceholder = fixture("ru.ts").replace(
|
||||||
|
'hero: "Привет, {name}!",',
|
||||||
|
'hero: "Привет!",',
|
||||||
|
);
|
||||||
|
const messages = lint(ruLostPlaceholder, "lib/i18n/ru.ts");
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messageIds(messages)).toEqual(["placeholderMismatch"]);
|
||||||
|
expect(messages[0].message).toContain('"home.hero"');
|
||||||
|
expect(messages[0].message).toContain("{} vs expected {name}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags an extra placeholder introduced in one locale", () => {
|
||||||
|
const enExtraPlaceholder = fixture("en.ts").replace(
|
||||||
|
'description: "Draws a {kind} frame.",',
|
||||||
|
'description: "Draws a {kind} frame by {author}.",',
|
||||||
|
);
|
||||||
|
const messages = lint(enExtraPlaceholder, "lib/i18n/en.ts");
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messageIds(messages)).toEqual(["placeholderMismatch"]);
|
||||||
|
expect(messages[0].message).toContain('"tools.addBorder.description"');
|
||||||
|
expect(messages[0].message).toContain("{author, kind} vs expected {kind}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours allowPaths for conscious deviations", () => {
|
||||||
|
const enMissing = fixture("en.ts").replace(
|
||||||
|
'\t\tsourceRequired: "Source image required",',
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const messages = lint(enMissing, "lib/i18n/en.ts", {
|
||||||
|
allowPaths: ["errors.sourceRequired"],
|
||||||
|
});
|
||||||
|
expect(messageIds(messages)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores files that are not locale dicts", () => {
|
||||||
|
const notALocale = `
|
||||||
|
export const helper = { a: "b" };
|
||||||
|
`;
|
||||||
|
expect(messageIds(lint(notALocale, "lib/i18n/t.ts"))).toEqual([]);
|
||||||
|
expect(messageIds(lint(notALocale, "lib/i18n/misc.ts"))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
/**
|
||||||
|
* Cross-locale dictionary linter (i18n/dict-consistency).
|
||||||
|
*
|
||||||
|
* Each locale dict in `lib/i18n/` is checked against ALL the others (not just
|
||||||
|
* against the base locale). The set of locales comes from `LOCALES` in the
|
||||||
|
* sibling `dict.ts`, so a new locale is picked up automatically — no rule
|
||||||
|
* changes needed. The rule only ever warns (a missing translation must not
|
||||||
|
* break the build) — see docs/plan-preview-i18n.md §Фаза 8.
|
||||||
|
*
|
||||||
|
* The diagnostic lives where the fix lives — each file reports its OWN gaps:
|
||||||
|
* - missing keys: the union of all keys is collected from the on-disk dicts;
|
||||||
|
* whatever the current file lacks is reported here (a missing whole
|
||||||
|
* section is reported once, not per descendant key);
|
||||||
|
* - empty values: `""` or whitespace-only values;
|
||||||
|
* - placeholders: for a shared key the `{name}` set is compared against the
|
||||||
|
* canonical set (the one shared by most locales, ties broken by LOCALES
|
||||||
|
* order) — catches a variable lost in translation exactly once, even when
|
||||||
|
* only one locale deviates.
|
||||||
|
*
|
||||||
|
* The rule self-filters: files whose basename is not one of LOCALES are
|
||||||
|
* ignored, so it can be attached to the whole `lib/i18n/` directory.
|
||||||
|
*
|
||||||
|
* Options (object, all optional):
|
||||||
|
* - allowPaths: dot-path keys excluded from every check (conscious
|
||||||
|
* deviations until the dict reaches zero-warn, see Фаза 8 §8.3).
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
const PLACEHOLDER = /\{(\w+)\}/g;
|
||||||
|
|
||||||
|
/** Parse a TS source string into an ESTree Program (syntax only). */
|
||||||
|
function parseTs(code, filename) {
|
||||||
|
const result = tseslint.parser.parseForESLint(code, {
|
||||||
|
filePath: filename,
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
});
|
||||||
|
return result.ast;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unwrap `as const` / `satisfies` wrappers around a value node. */
|
||||||
|
function unwrap(node) {
|
||||||
|
while (
|
||||||
|
node &&
|
||||||
|
(node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression")
|
||||||
|
) {
|
||||||
|
node = node.expression;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exported dict `export const <name>: Dict = {...}` — the declarator id
|
||||||
|
* (for reporting) and the object literal, or null.
|
||||||
|
*/
|
||||||
|
function findDictExport(ast) {
|
||||||
|
for (const node of ast.body) {
|
||||||
|
if (node.type !== "ExportNamedDeclaration") continue;
|
||||||
|
const decl = node.declaration;
|
||||||
|
if (!decl || decl.type !== "VariableDeclaration") continue;
|
||||||
|
const d = decl.declarations[0];
|
||||||
|
if (!d || d.id.type !== "Identifier") continue;
|
||||||
|
const init = unwrap(d.init);
|
||||||
|
if (init && init.type === "ObjectExpression") {
|
||||||
|
return { id: d.id, object: init };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `LOCALES` list exported from `dict.ts`, or null. */
|
||||||
|
function findLocalesList(ast) {
|
||||||
|
for (const node of ast.body) {
|
||||||
|
if (node.type !== "ExportNamedDeclaration") continue;
|
||||||
|
const decl = node.declaration;
|
||||||
|
if (!decl || decl.type !== "VariableDeclaration") continue;
|
||||||
|
for (const d of decl.declarations) {
|
||||||
|
if (d.id.type !== "Identifier" || d.id.name !== "LOCALES") continue;
|
||||||
|
const init = unwrap(d.init);
|
||||||
|
if (init && init.type === "ArrayExpression") {
|
||||||
|
return init.elements
|
||||||
|
.map((el) => (isStringLiteral(el) ? el.value : null))
|
||||||
|
.filter((v) => typeof v === "string");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String literal node. The parser emits `Literal` in some ESTree versions and
|
||||||
|
* `StringLiteral` in others — accept both.
|
||||||
|
*/
|
||||||
|
function isStringLiteral(node) {
|
||||||
|
if (!node) return false;
|
||||||
|
if (node.type === "StringLiteral") return true;
|
||||||
|
return node.type === "Literal" && typeof node.value === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
const OBJECT = "object";
|
||||||
|
const STRING = "string";
|
||||||
|
const OPAQUE = "opaque";
|
||||||
|
|
||||||
|
/** Leaf kind of a property value (objects are followed, the rest are opaque). */
|
||||||
|
function entryKind(node) {
|
||||||
|
if (node.type === "ObjectExpression") return OBJECT;
|
||||||
|
if (isStringLiteral(node)) return STRING;
|
||||||
|
if (
|
||||||
|
node.type === "TemplateLiteral" &&
|
||||||
|
node.expressions.length === 0 &&
|
||||||
|
node.quasis.length === 1
|
||||||
|
) {
|
||||||
|
return STRING;
|
||||||
|
}
|
||||||
|
return OPAQUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryValue(node) {
|
||||||
|
if (isStringLiteral(node)) return node.value;
|
||||||
|
if (node.type === "TemplateLiteral") return node.quasis[0].value.cooked;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function propKey(prop) {
|
||||||
|
if (prop.key.type === "Identifier") return prop.key.name;
|
||||||
|
if (isStringLiteral(prop.key)) return prop.key.value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten an exported dict object into `path -> { node, kind, value }`.
|
||||||
|
* Computed properties are skipped; intermediate objects are recorded too, so a
|
||||||
|
* missing whole section is reported once (children of a missing path are
|
||||||
|
* skipped when reporting).
|
||||||
|
*/
|
||||||
|
function buildKeyMap(objectNode) {
|
||||||
|
const map = new Map();
|
||||||
|
const stack = [[objectNode, []]];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const [obj, prefix] = stack.pop();
|
||||||
|
for (const prop of obj.properties) {
|
||||||
|
if (prop.type !== "Property" || prop.computed) continue;
|
||||||
|
const key = propKey(prop);
|
||||||
|
if (key == null) continue;
|
||||||
|
const dot = [...prefix, key].join(".");
|
||||||
|
const kind = entryKind(prop.value);
|
||||||
|
map.set(dot, {
|
||||||
|
node: prop,
|
||||||
|
kind,
|
||||||
|
value: kind === STRING ? entryValue(prop.value) : null,
|
||||||
|
});
|
||||||
|
if (kind === OBJECT) stack.push([prop.value, [...prefix, key]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholders(value) {
|
||||||
|
const set = new Set();
|
||||||
|
for (const m of String(value).matchAll(PLACEHOLDER)) set.add(m[1]);
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSet(set) {
|
||||||
|
return `{${[...set].sort().join(", ")}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ancestor paths of a dot-path (e.g. `a.b` and `a` for `a.b.c`). */
|
||||||
|
function* ancestorPaths(key) {
|
||||||
|
let i = key.lastIndexOf(".");
|
||||||
|
while (i !== -1) {
|
||||||
|
yield key.slice(0, i);
|
||||||
|
i = key.lastIndexOf(".", i - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sibling dicts are read once per directory and cached for the whole lint run
|
||||||
|
// (mirrors how no-undefined-in-svelte caches the token dictionary).
|
||||||
|
const dirCache = new Map();
|
||||||
|
|
||||||
|
function loadDir(dir) {
|
||||||
|
if (!dirCache.has(dir)) dirCache.set(dir, readDir(dir));
|
||||||
|
return dirCache.get(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDir(dir) {
|
||||||
|
const dictPath = path.join(dir, "dict.ts");
|
||||||
|
if (!fs.existsSync(dictPath)) return null;
|
||||||
|
const ast = parseTs(fs.readFileSync(dictPath, "utf8"), dictPath);
|
||||||
|
const locales = findLocalesList(ast);
|
||||||
|
if (!locales || locales.length === 0) return null;
|
||||||
|
|
||||||
|
const dicts = [];
|
||||||
|
for (const locale of locales) {
|
||||||
|
const file = path.join(dir, `${locale}.ts`);
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
// Declared in LOCALES but not yet translated — treated as an empty
|
||||||
|
// dict so every key it lacks reports `missing key ... in <locale>`.
|
||||||
|
dicts.push({ locale, map: new Map() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dictAst = parseTs(fs.readFileSync(file, "utf8"), file);
|
||||||
|
const dictExport = findDictExport(dictAst);
|
||||||
|
if (!dictExport) continue;
|
||||||
|
dicts.push({ locale, map: buildKeyMap(dictExport.object) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union of every key across all locales (intermediate paths included), so a
|
||||||
|
// file can report exactly what it is missing.
|
||||||
|
const unionPaths = new Set();
|
||||||
|
// Non-empty string leaves per key: loccales contribute to the canonical
|
||||||
|
// placeholder set voting (empty values are already broken on their own).
|
||||||
|
const stringLeaves = new Map();
|
||||||
|
for (const d of dicts) {
|
||||||
|
for (const [key, entry] of d.map) {
|
||||||
|
unionPaths.add(key);
|
||||||
|
if (entry.kind === STRING && (entry.value ?? "").trim() !== "") {
|
||||||
|
if (!stringLeaves.has(key)) stringLeaves.set(key, []);
|
||||||
|
stringLeaves.get(key).push(placeholders(entry.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical placeholder set per key: the set shared by the most locales;
|
||||||
|
// on a tie the first one encountered (LOCALES order) wins.
|
||||||
|
const canonicalSets = new Map();
|
||||||
|
for (const [key, sets] of stringLeaves) {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const set of sets) {
|
||||||
|
const sig = [...set].sort().join("\u0000");
|
||||||
|
counts.set(sig, (counts.get(sig) || 0) + 1);
|
||||||
|
}
|
||||||
|
let bestCount = 0;
|
||||||
|
for (const set of sets) {
|
||||||
|
const sig = [...set].sort().join("\u0000");
|
||||||
|
if (counts.get(sig) > bestCount) {
|
||||||
|
bestCount = counts.get(sig);
|
||||||
|
canonicalSets.set(key, set);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { locales, dicts, unionPaths, canonicalSets };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
meta: {
|
||||||
|
type: "suggestion",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Check cross-locale consistency of the i18n dicts in lib/i18n (warn-only).",
|
||||||
|
category: "Best Practices",
|
||||||
|
},
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
allowPaths: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
messages: {
|
||||||
|
missingKey: 'missing key "{{key}}" in {{locale}}',
|
||||||
|
emptyValue: 'empty value "{{key}}" in {{locale}}',
|
||||||
|
placeholderMismatch:
|
||||||
|
'placeholder mismatch for "{{key}}" in {{locale}}: {{active}} vs expected {{expected}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const options = context.options[0] || {};
|
||||||
|
const allowPaths = new Set(options.allowPaths || []);
|
||||||
|
|
||||||
|
const filename = path.resolve(
|
||||||
|
context.filename || context.physicalFilename || "",
|
||||||
|
);
|
||||||
|
const locale = path.basename(filename, path.extname(filename));
|
||||||
|
const dirInfo = loadDir(path.dirname(filename));
|
||||||
|
if (!dirInfo || !dirInfo.locales.includes(locale)) return {};
|
||||||
|
|
||||||
|
const dictExport = findDictExport(context.sourceCode.ast);
|
||||||
|
if (!dictExport) return {};
|
||||||
|
|
||||||
|
const own = buildKeyMap(dictExport.object);
|
||||||
|
|
||||||
|
// Missing keys: everything the union has but the current file lacks.
|
||||||
|
// Top-level gaps only — a missing ancestor already covers its subtree.
|
||||||
|
const missing = [...dirInfo.unionPaths].filter((k) => !own.has(k));
|
||||||
|
const missingSet = new Set(missing);
|
||||||
|
const topMissing = missing.filter(
|
||||||
|
(k) => ![...ancestorPaths(k)].some((a) => missingSet.has(a)),
|
||||||
|
);
|
||||||
|
for (const key of topMissing) {
|
||||||
|
if (allowPaths.has(key)) continue;
|
||||||
|
context.report({
|
||||||
|
node: dictExport.id,
|
||||||
|
messageId: "missingKey",
|
||||||
|
data: { key, locale },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty values and shared-key placeholder parity.
|
||||||
|
for (const [key, entry] of own) {
|
||||||
|
if (allowPaths.has(key) || entry.kind !== STRING) continue;
|
||||||
|
if ((entry.value ?? "").trim() === "") {
|
||||||
|
context.report({
|
||||||
|
node: entry.node,
|
||||||
|
messageId: "emptyValue",
|
||||||
|
data: { key, locale },
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const canonical = dirInfo.canonicalSets.get(key);
|
||||||
|
if (!canonical) continue;
|
||||||
|
const active = placeholders(entry.value);
|
||||||
|
if (
|
||||||
|
active.size === canonical.size &&
|
||||||
|
[...active].every((p) => canonical.has(p))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
context.report({
|
||||||
|
node: entry.node,
|
||||||
|
messageId: "placeholderMismatch",
|
||||||
|
data: {
|
||||||
|
key,
|
||||||
|
locale,
|
||||||
|
active: formatSet(active),
|
||||||
|
expected: formatSet(canonical),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Local ESLint plugin "i18n".
|
||||||
|
*
|
||||||
|
* Cross-locale dictionary hygiene (i18n/dict-consistency): every locale dict in
|
||||||
|
* `lib/i18n/` is compared against all the others — key parity, empty values,
|
||||||
|
* `{placeholder}` parity — warn-only by design (a missing translation must not
|
||||||
|
* break the build). The set of locales comes from `LOCALES` in `dict.ts`, so
|
||||||
|
* new locales are picked up automatically.
|
||||||
|
*/
|
||||||
|
import dictConsistency from "./dict-consistency.js";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
meta: {
|
||||||
|
name: "i18n",
|
||||||
|
version: "0.1.0",
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"dict-consistency": dictConsistency,
|
||||||
|
},
|
||||||
|
};
|
||||||
+21
-1
@@ -7,6 +7,7 @@ import tseslint from "typescript-eslint";
|
|||||||
import designTokens from "./eslint-plugins/index.js";
|
import designTokens from "./eslint-plugins/index.js";
|
||||||
import isolationPlugin from "./eslint-plugins/isolation/index.js";
|
import isolationPlugin from "./eslint-plugins/isolation/index.js";
|
||||||
import conventionsPlugin from "./eslint-plugins/conventions/index.js";
|
import conventionsPlugin from "./eslint-plugins/conventions/index.js";
|
||||||
|
import i18nPlugin from "./eslint-plugins/i18n/index.js";
|
||||||
|
|
||||||
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
|
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
|
||||||
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
|
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
|
||||||
@@ -72,6 +73,7 @@ export default tseslint.config(
|
|||||||
"e2e/tools-smoke.spec.ts",
|
"e2e/tools-smoke.spec.ts",
|
||||||
"e2e/generators.spec.ts",
|
"e2e/generators.spec.ts",
|
||||||
"e2e/known-issues.spec.ts",
|
"e2e/known-issues.spec.ts",
|
||||||
|
"e2e/i18n.spec.ts",
|
||||||
// Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в
|
// Тесты и фикстуры кастомных линт-правил лежат вне src/ (не в
|
||||||
// tsconfig), поэтому для типизированного парсинга резолвятся
|
// tsconfig), поэтому для типизированного парсинга резолвятся
|
||||||
// через default-проект. Перечисляются точечно: `**` в
|
// через default-проект. Перечисляются точечно: `**` в
|
||||||
@@ -81,6 +83,11 @@ export default tseslint.config(
|
|||||||
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
|
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
|
||||||
"eslint-plugins/__tests__/helpers.ts",
|
"eslint-plugins/__tests__/helpers.ts",
|
||||||
"eslint-plugins/__tests__/no-mixed-imports.test.ts",
|
"eslint-plugins/__tests__/no-mixed-imports.test.ts",
|
||||||
|
"eslint-plugins/__tests__/dict-consistency.test.ts",
|
||||||
|
"eslint-plugins/__fixtures__/src/lib/i18n/dict.ts",
|
||||||
|
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
|
||||||
|
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
|
||||||
|
"eslint-plugins/__fixtures__/src/lib/i18n/de.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/v1/old.ts",
|
"eslint-plugins/__fixtures__/src/lib/v1/old.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts",
|
"eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts",
|
||||||
"eslint-plugins/__fixtures__/src/lib/core/errors.ts",
|
"eslint-plugins/__fixtures__/src/lib/core/errors.ts",
|
||||||
@@ -91,7 +98,7 @@ export default tseslint.config(
|
|||||||
"eslint-plugins/__fixtures__/src/routes/+page.svelte",
|
"eslint-plugins/__fixtures__/src/routes/+page.svelte",
|
||||||
"eslint-plugins/__fixtures__/src/routes/v1/+layout.svelte",
|
"eslint-plugins/__fixtures__/src/routes/v1/+layout.svelte",
|
||||||
],
|
],
|
||||||
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 32,
|
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
|
||||||
},
|
},
|
||||||
extraFileExtensions: [".svelte"],
|
extraFileExtensions: [".svelte"],
|
||||||
},
|
},
|
||||||
@@ -167,6 +174,19 @@ export default tseslint.config(
|
|||||||
"conventions/no-string-union-alias": "error",
|
"conventions/no-string-union-alias": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency).
|
||||||
|
// warn-only: пропущенный перевод не должен валить сборку. Список локалей
|
||||||
|
// берётся из LOCALES в lib/i18n/dict.ts — новые локали подхватываются
|
||||||
|
// автоматически; правило игнорирует не-локали в этой папке само.
|
||||||
|
{
|
||||||
|
files: ["**/src/lib/i18n/*.ts"],
|
||||||
|
plugins: {
|
||||||
|
i18n: i18nPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"i18n/dict-consistency": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
// Полные recommended-наборы — только на новый код.
|
// Полные recommended-наборы — только на новый код.
|
||||||
...[
|
...[
|
||||||
...jsRecommended.map((cfg) => ({
|
...jsRecommended.map((cfg) => ({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
fileResult?: FileResult | null;
|
fileResult?: FileResult | null;
|
||||||
textSource?: string;
|
textSource?: string;
|
||||||
textResult?: string | null;
|
textResult?: string | null;
|
||||||
|
textVars?: Record<string, string | number>;
|
||||||
running?: boolean;
|
running?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
onupload: (file: File) => void;
|
onupload: (file: File) => void;
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
fileResult = null,
|
fileResult = null,
|
||||||
textSource = "",
|
textSource = "",
|
||||||
textResult = null,
|
textResult = null,
|
||||||
|
textVars = undefined,
|
||||||
running = false,
|
running = false,
|
||||||
error = "",
|
error = "",
|
||||||
onupload,
|
onupload,
|
||||||
@@ -106,6 +108,7 @@
|
|||||||
{result}
|
{result}
|
||||||
{fileResult}
|
{fileResult}
|
||||||
{textResult}
|
{textResult}
|
||||||
|
{textVars}
|
||||||
{toolId}
|
{toolId}
|
||||||
{running}
|
{running}
|
||||||
oncopy={oncopytext}
|
oncopy={oncopytext}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
result: PixelImage | null;
|
result: PixelImage | null;
|
||||||
fileResult?: FileResult | null;
|
fileResult?: FileResult | null;
|
||||||
textResult?: string | null;
|
textResult?: string | null;
|
||||||
|
textVars?: Record<string, string | number>;
|
||||||
toolId?: string;
|
toolId?: string;
|
||||||
running?: boolean;
|
running?: boolean;
|
||||||
oncopy?: () => void;
|
oncopy?: () => void;
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
result,
|
result,
|
||||||
fileResult = null,
|
fileResult = null,
|
||||||
textResult = null,
|
textResult = null,
|
||||||
|
textVars = undefined,
|
||||||
toolId = "",
|
toolId = "",
|
||||||
running = false,
|
running = false,
|
||||||
oncopy,
|
oncopy,
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
<SchemaTextResult
|
<SchemaTextResult
|
||||||
value={textResult}
|
value={textResult}
|
||||||
kind="verdict"
|
kind="verdict"
|
||||||
|
vars={textVars}
|
||||||
{toolId}
|
{toolId}
|
||||||
oncopy={oncopy ?? (() => {})}
|
oncopy={oncopy ?? (() => {})}
|
||||||
ondownload={ondownloadtxt ?? (() => {})}
|
ondownload={ondownloadtxt ?? (() => {})}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Copy, Download, FileText } from "@lucide/svelte";
|
import { verdictText, verdictTone } from "$lib/i18n/schema-tool-strings";
|
||||||
import { verdictTone, verdictText } from "$lib/i18n/schema-tool-strings";
|
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
|
import { Copy, Download, FileText } from "@lucide/svelte";
|
||||||
import IconButton from "./ui/IconButton.svelte";
|
import IconButton from "./ui/IconButton.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
kind: "text" | "verdict";
|
kind: "text" | "verdict";
|
||||||
toolId?: string;
|
toolId?: string;
|
||||||
|
vars?: Record<string, string | number>;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
oncopy: () => void;
|
oncopy: () => void;
|
||||||
ondownload: () => void;
|
ondownload: () => void;
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
value,
|
value,
|
||||||
kind,
|
kind,
|
||||||
toolId = "",
|
toolId = "",
|
||||||
|
vars = undefined,
|
||||||
fileName = "result.txt",
|
fileName = "result.txt",
|
||||||
oncopy,
|
oncopy,
|
||||||
ondownload,
|
ondownload,
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
|
|
||||||
const tone = $derived(verdictTone(value));
|
const tone = $derived(verdictTone(value));
|
||||||
const displayValue = $derived(
|
const displayValue = $derived(
|
||||||
kind === "verdict" && toolId ? verdictText(toolId, value) : value,
|
kind === "verdict" && toolId ? verdictText(toolId, value, vars) : value,
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@
|
|||||||
let fileResult = $state<FileResult | null>(null);
|
let fileResult = $state<FileResult | null>(null);
|
||||||
let textSource = $state("");
|
let textSource = $state("");
|
||||||
let textResult = $state<string | null>(null);
|
let textResult = $state<string | null>(null);
|
||||||
|
let verdictVars = $state<Record<string, string | number> | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
let running = $state(false);
|
let running = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let started = $state(false);
|
let started = $state(false);
|
||||||
@@ -59,6 +62,7 @@
|
|||||||
result = null;
|
result = null;
|
||||||
fileResult = null;
|
fileResult = null;
|
||||||
textResult = null;
|
textResult = null;
|
||||||
|
verdictVars = undefined;
|
||||||
error = "";
|
error = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +94,16 @@
|
|||||||
result = null;
|
result = null;
|
||||||
textResult = null;
|
textResult = null;
|
||||||
} else {
|
} 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;
|
result = null;
|
||||||
fileResult = null;
|
fileResult = null;
|
||||||
}
|
}
|
||||||
@@ -125,7 +138,9 @@
|
|||||||
async function copyText() {
|
async function copyText() {
|
||||||
if (!textResult) return;
|
if (!textResult) return;
|
||||||
const copyValue =
|
const copyValue =
|
||||||
resultKind === "verdict" ? verdictText(tool.id, textResult) : textResult;
|
resultKind === "verdict"
|
||||||
|
? verdictText(tool.id, textResult, verdictVars)
|
||||||
|
: textResult;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(copyValue);
|
await navigator.clipboard.writeText(copyValue);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -196,6 +211,7 @@
|
|||||||
{fileResult}
|
{fileResult}
|
||||||
{textSource}
|
{textSource}
|
||||||
{textResult}
|
{textResult}
|
||||||
|
textVars={verdictVars}
|
||||||
{inputMode}
|
{inputMode}
|
||||||
{resultKind}
|
{resultKind}
|
||||||
{running}
|
{running}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ function ensureWorker(): Worker | null {
|
|||||||
data: Uint8ClampedArray;
|
data: Uint8ClampedArray;
|
||||||
}[];
|
}[];
|
||||||
text?: string;
|
text?: string;
|
||||||
|
textVars?: Record<string, string | number>;
|
||||||
error?: string;
|
error?: string;
|
||||||
errorKey?: string;
|
errorKey?: string;
|
||||||
errorVars?: Record<string, string | number>;
|
errorVars?: Record<string, string | number>;
|
||||||
@@ -120,7 +121,11 @@ function ensureWorker(): Worker | null {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
} else if (payload.ok && typeof payload.text === "string") {
|
} 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) {
|
} else if (payload.errorKey) {
|
||||||
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
|
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type WorkerResponse =
|
|||||||
height?: number;
|
height?: number;
|
||||||
data?: Uint8ClampedArray;
|
data?: Uint8ClampedArray;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
textVars?: Record<string, string | number>;
|
||||||
files?: WorkerImageFile[];
|
files?: WorkerImageFile[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -67,6 +68,15 @@ async function handle(request: WorkerRequest): Promise<void> {
|
|||||||
} satisfies WorkerResponse);
|
} satisfies WorkerResponse);
|
||||||
return;
|
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) {
|
if ("files" in output) {
|
||||||
const files = output.files.map((file) => ({
|
const files = output.files.map((file) => ({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
|
|||||||
+17
-29
@@ -525,64 +525,52 @@ export const ru: Dict = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"png-to-hsl": {
|
"png-to-hsl": {
|
||||||
|
title: "Разбить PNG на HSL",
|
||||||
|
description:
|
||||||
|
"Раскладывает изображение на компоненты тона, насыщенности и светлоты.",
|
||||||
options: {
|
options: {
|
||||||
display: { gray: "Оттенки серого", color: "Пространство как RGB" },
|
display: { gray: "Оттенки серого", color: "Пространство как RGB" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"png-to-hsv": {
|
"png-to-hsv": {
|
||||||
|
title: "Разбить PNG на HSV",
|
||||||
|
description:
|
||||||
|
"Раскладывает изображение на компоненты тона, насыщенности и значения (яркости).",
|
||||||
options: {
|
options: {
|
||||||
display: { gray: "Оттенки серого", color: "Пространство как RGB" },
|
display: { gray: "Оттенки серого", color: "Пространство как RGB" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"png-to-hsi": {
|
"png-to-hsi": {
|
||||||
|
title: "Разбить PNG на HSI",
|
||||||
|
description:
|
||||||
|
"Раскладывает изображение на компоненты тона, насыщенности и интенсивности.",
|
||||||
options: {
|
options: {
|
||||||
display: { gray: "Оттенки серого", color: "Пространство как RGB" },
|
display: { gray: "Оттенки серого", color: "Пространство как RGB" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"png-to-cmyk": {
|
"png-to-cmyk": {
|
||||||
|
title: "Конвертировать PNG в цвета CMYK",
|
||||||
|
description:
|
||||||
|
"Раскладывает изображение на печатные компоненты Cyan, Magenta, Yellow и Key (чёрный).",
|
||||||
options: {
|
options: {
|
||||||
display: { gray: "Оттенки серого", color: "Пространство как RGB" },
|
display: { gray: "Оттенки серого", color: "Пространство как RGB" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"png-to-ycbcr": {
|
"png-to-ycbcr": {
|
||||||
|
title: "Конвертировать PNG в цвета YCbCr",
|
||||||
|
description:
|
||||||
|
"Раскладывает изображение на яркость (Y) и цветоразностные компоненты Cb / Cr.",
|
||||||
options: {
|
options: {
|
||||||
display: { gray: "Оттенки серого", color: "Пространство как RGB" },
|
display: { gray: "Оттенки серого", color: "Пространство как RGB" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"png-to-lab": {
|
"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",
|
title: "Конвертировать PNG в цвета LAB",
|
||||||
description:
|
description:
|
||||||
"Раскладывает изображение на перцепционную светлоту и оппонентные пары зелёный–пурпур / синий–жёлтый.",
|
"Раскладывает изображение на перцепционную светлоту и оппонентные пары зелёный–пурпур / синий–жёлтый.",
|
||||||
|
options: {
|
||||||
|
display: { gray: "Оттенки серого", color: "Пространство как RGB" },
|
||||||
},
|
},
|
||||||
ycbcr: {
|
|
||||||
title: "Конвертировать PNG в цвета YCbCr",
|
|
||||||
description:
|
|
||||||
"Раскладывает изображение на яркость (Y) и цветоразностные компоненты Cb / Cr.",
|
|
||||||
},
|
},
|
||||||
"black-and-white-png": {
|
"black-and-white-png": {
|
||||||
title: "Чёрно-белый 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 { ToolEntry } from "$lib/registry";
|
||||||
import type { Field } from "$lib/registry-schema";
|
import type { Field } from "$lib/registry-schema";
|
||||||
import { ru } from "./ru";
|
|
||||||
import { getMergedDict } from "./locale.svelte";
|
import { getMergedDict } from "./locale.svelte";
|
||||||
import type { SearchDoc } from "./matching";
|
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
|
||||||
import { t } from "./t";
|
import { ru } from "./ru";
|
||||||
|
import { interpolate, t } from "./t";
|
||||||
|
|
||||||
function labelOf(id: string): string {
|
function labelOf(id: string): string {
|
||||||
return id
|
return id
|
||||||
@@ -43,8 +43,15 @@ export function optionLabel(
|
|||||||
return getMergedDict().tools[toolId]?.options?.[fieldId]?.[value] ?? fallback;
|
return getMergedDict().tools[toolId]?.options?.[fieldId]?.[value] ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verdictText(toolId: string, key: string): string {
|
export function verdictText(
|
||||||
return getMergedDict().tools[toolId]?.results?.[key] ?? key;
|
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" {
|
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,
|
renderPredicateMask,
|
||||||
} from "../core/masks";
|
} from "../core/masks";
|
||||||
import { base64ToBytes, looksLikePng, stripDataUri } from "../core/textio";
|
import { base64ToBytes, looksLikePng, stripDataUri } from "../core/textio";
|
||||||
import { t } from "../i18n/t";
|
|
||||||
import { field, toolSchema } from "../registry-schema";
|
import { field, toolSchema } from "../registry-schema";
|
||||||
import { imgTool, textGen, type ToolEntry } from "./types";
|
import { imgTool, textGen, type ToolEntry } from "./types";
|
||||||
|
|
||||||
@@ -277,7 +276,7 @@ const pngFileSize: ToolEntry<NoParams> = {
|
|||||||
const blob = await encode(img, "image/png");
|
const blob = await encode(img, "image/png");
|
||||||
const kb = blob.size / 1024;
|
const kb = blob.size / 1024;
|
||||||
const kbText = kb >= 100 ? Math.round(kb).toString() : kb.toFixed(1);
|
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 { alphaEntries } from "./alpha";
|
||||||
import { convertEntries } from "./convert";
|
|
||||||
import { analyzeEntries } from "./analyze";
|
import { analyzeEntries } from "./analyze";
|
||||||
import { filtersEntries } from "./filters";
|
|
||||||
import { colorEntries } from "./color";
|
import { colorEntries } from "./color";
|
||||||
|
import { convertEntries } from "./convert";
|
||||||
|
import { filtersEntries } from "./filters";
|
||||||
import { generateEntries } from "./generate";
|
import { generateEntries } from "./generate";
|
||||||
|
import { geometryEntries } from "./geometry";
|
||||||
import { textEntries } from "./text";
|
import { textEntries } from "./text";
|
||||||
|
import type { ToolEntry } from "./types";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
genTool,
|
genTool,
|
||||||
imgTool,
|
imgTool,
|
||||||
textGen,
|
INPUT_MODES,
|
||||||
requireSource,
|
requireSource,
|
||||||
requireText,
|
requireText,
|
||||||
INPUT_MODES,
|
|
||||||
RESULT_KINDS,
|
RESULT_KINDS,
|
||||||
|
textGen,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
export type {
|
export type {
|
||||||
|
FileResult,
|
||||||
InputMode,
|
InputMode,
|
||||||
ResultKind,
|
ResultKind,
|
||||||
ToolContext,
|
ToolContext,
|
||||||
ToolEntry,
|
ToolEntry,
|
||||||
ToolResult,
|
|
||||||
ToolImageFile,
|
ToolImageFile,
|
||||||
FileResult,
|
ToolResult,
|
||||||
|
VerdictResult,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const TOOLS: ToolEntry[] = [
|
export const TOOLS: ToolEntry[] = [
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
|
|||||||
import type { FileResult, ToolResult } from "./types";
|
import type { FileResult, ToolResult } from "./types";
|
||||||
|
|
||||||
function asImage(result: ToolResult): PixelImage {
|
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");
|
throw new Error("expected an image result");
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -595,7 +595,7 @@ describe("split-into-parts-png", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function asFiles(result: ToolResult): FileResult {
|
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");
|
throw new Error("expected a files result");
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -49,7 +49,18 @@ export interface FileResult {
|
|||||||
files: ToolImageFile[];
|
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`, схема —
|
* Инструмент нового registry: полностью типизирован на `Params`, схема —
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import Footer from "$lib/components/layout/Footer.svelte";
|
import Footer from "$lib/components/layout/Footer.svelte";
|
||||||
import TopBar from "$lib/components/layout/TopBar.svelte";
|
import TopBar from "$lib/components/layout/TopBar.svelte";
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href="/favicon.svg" />
|
<link rel="icon" href={`${resolve("/")}favicon.svg`} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="preview-root" data-theme={theme}>
|
<main class="preview-root" data-theme={theme}>
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
import CatalogHeader from "$lib/components/CatalogHeader.svelte";
|
import CatalogHeader from "$lib/components/CatalogHeader.svelte";
|
||||||
import CatalogToolbar from "$lib/components/CatalogToolbar.svelte";
|
import CatalogToolbar from "$lib/components/CatalogToolbar.svelte";
|
||||||
import ToolCard from "$lib/components/ToolCard.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 { t } from "$lib/i18n/t";
|
||||||
import { TOOL_ICONS } from "$lib/tool-icons";
|
import { TOOL_ICONS } from "$lib/tool-icons";
|
||||||
|
|
||||||
@@ -14,12 +19,8 @@
|
|||||||
PREVIEW_GROUPS.map((g) => ({
|
PREVIEW_GROUPS.map((g) => ({
|
||||||
id: g.id,
|
id: g.id,
|
||||||
label: t(`categories.${g.id}`),
|
label: t(`categories.${g.id}`),
|
||||||
tools: g.tools.filter(
|
tools: searchTools(g.tools, query).filter(
|
||||||
(t) =>
|
() => category === "all" || category === g.id,
|
||||||
(category === "all" || category === g.id) &&
|
|
||||||
(query.trim() === "" ||
|
|
||||||
t.title.toLowerCase().includes(query.trim().toLowerCase()) ||
|
|
||||||
t.description.toLowerCase().includes(query.trim().toLowerCase())),
|
|
||||||
),
|
),
|
||||||
})).filter((g) => g.tools.length > 0),
|
})).filter((g) => g.tools.length > 0),
|
||||||
);
|
);
|
||||||
@@ -35,9 +36,9 @@
|
|||||||
<CatalogGroup label={group.label} count={group.tools.length}>
|
<CatalogGroup label={group.label} count={group.tools.length}>
|
||||||
{#each group.tools as tool, i (tool.id)}
|
{#each group.tools as tool, i (tool.id)}
|
||||||
<ToolCard
|
<ToolCard
|
||||||
title={tool.title}
|
title={toolTitle(tool)}
|
||||||
id={tool.id}
|
id={tool.id}
|
||||||
description={tool.description}
|
description={toolDescription(tool)}
|
||||||
index={i + 1}
|
index={i + 1}
|
||||||
icon={TOOL_ICONS[tool.id]}
|
icon={TOOL_ICONS[tool.id]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
import CatalogHeader from "$lib/components/CatalogHeader.svelte";
|
import CatalogHeader from "$lib/components/CatalogHeader.svelte";
|
||||||
import CatalogToolbar from "$lib/components/CatalogToolbar.svelte";
|
import CatalogToolbar from "$lib/components/CatalogToolbar.svelte";
|
||||||
import ToolCard from "$lib/components/ToolCard.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 { t } from "$lib/i18n/t";
|
||||||
import { TOOL_ICONS } from "$lib/tool-icons";
|
import { TOOL_ICONS } from "$lib/tool-icons";
|
||||||
|
|
||||||
@@ -14,12 +19,8 @@
|
|||||||
PREVIEW_GROUPS.map((g) => ({
|
PREVIEW_GROUPS.map((g) => ({
|
||||||
id: g.id,
|
id: g.id,
|
||||||
label: t(`categories.${g.id}`),
|
label: t(`categories.${g.id}`),
|
||||||
tools: g.tools.filter(
|
tools: searchTools(g.tools, query).filter(
|
||||||
(t) =>
|
() => category === "all" || category === g.id,
|
||||||
(category === "all" || category === g.id) &&
|
|
||||||
(query.trim() === "" ||
|
|
||||||
t.title.toLowerCase().includes(query.trim().toLowerCase()) ||
|
|
||||||
t.description.toLowerCase().includes(query.trim().toLowerCase())),
|
|
||||||
),
|
),
|
||||||
})).filter((g) => g.tools.length > 0),
|
})).filter((g) => g.tools.length > 0),
|
||||||
);
|
);
|
||||||
@@ -33,9 +34,9 @@
|
|||||||
<CatalogGroup label={group.label} count={group.tools.length}>
|
<CatalogGroup label={group.label} count={group.tools.length}>
|
||||||
{#each group.tools as tool, i (tool.id)}
|
{#each group.tools as tool, i (tool.id)}
|
||||||
<ToolCard
|
<ToolCard
|
||||||
title={tool.title}
|
title={toolTitle(tool)}
|
||||||
id={tool.id}
|
id={tool.id}
|
||||||
description={tool.description}
|
description={toolDescription(tool)}
|
||||||
index={i + 1}
|
index={i + 1}
|
||||||
icon={TOOL_ICONS[tool.id]}
|
icon={TOOL_ICONS[tool.id]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
|
import { getTheme, initTheme, setTheme } from "$lib/theme.svelte";
|
||||||
import { LOCALES, type Locale } from "$lib/v1/i18n/dict";
|
import { LOCALES, type Locale } from "$lib/v1/i18n/dict";
|
||||||
import { getLocale, initLocale, setLocale } from "$lib/v1/i18n/locale.svelte";
|
import { getLocale, initLocale, setLocale } from "$lib/v1/i18n/locale.svelte";
|
||||||
import { t } from "$lib/v1/i18n/t";
|
import { t } from "$lib/v1/i18n/t";
|
||||||
import { getTheme, initTheme, setTheme } from "$lib/theme.svelte";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import "../../app_v1.css";
|
import "../../app_v1.css";
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href="/favicon.svg" />
|
<link rel="icon" href={`${resolve("/")}favicon.svg`} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
|
|||||||
Reference in New Issue
Block a user