mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
test: less fragile - use semantic locators\helpers
This commit is contained in:
@@ -35,6 +35,14 @@
|
|||||||
Типизация пропсов через локальный `interface Props` закреплена правилом
|
Типизация пропсов через локальный `interface Props` закреплена правилом
|
||||||
`conventions/interface-props`; руками его нигде дублировать не надо.
|
`conventions/interface-props`; руками его нигде дублировать не надо.
|
||||||
|
|
||||||
|
### E2E
|
||||||
|
|
||||||
|
- Сначала используй `role`, `aria-*`, label и стабильный `href`; `data-testid`
|
||||||
|
разрешён только для source/result/verdict/empty-state.
|
||||||
|
- `test.fixme` допускается только для зарегистрированной задачи в backlog или
|
||||||
|
checklist; после исправления тест и ссылку обновляют вместе.
|
||||||
|
- Полные переводы проверяются в unit-тестах; e2e проверяет поведение.
|
||||||
|
|
||||||
### Сначала искать
|
### Сначала искать
|
||||||
|
|
||||||
Новая константа или тип не заводится, пока не произведён поиск существующего
|
Новая константа или тип не заводится, пока не произведён поиск существующего
|
||||||
|
|||||||
+6
-5
@@ -20,13 +20,14 @@
|
|||||||
| `AGENTS.md` | Краткий обязательный контракт для агента | active |
|
| `AGENTS.md` | Краткий обязательный контракт для агента | active |
|
||||||
| `docs/plan-codebase-quality.md` | План quality gates, тестов и lint-правил | draft |
|
| `docs/plan-codebase-quality.md` | План quality gates, тестов и lint-правил | draft |
|
||||||
| `docs/agent-workflow.md` | Подробный порядок исследования, изменения и проверки | active |
|
| `docs/agent-workflow.md` | Подробный порядок исследования, изменения и проверки | active |
|
||||||
|
| `docs/testing-strategy.md` | Locator, fixture, i18n и fixme-policy для e2e | active |
|
||||||
| `docs/quality-gates.md` | Команды проверки, warnings и CI | active |
|
| `docs/quality-gates.md` | Команды проверки, warnings и CI | active |
|
||||||
| `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active |
|
| `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active |
|
||||||
| `docs/roadmap.md` | Общие направления развития | active |
|
| `docs/roadmap.md` | Общие направления развития | active |
|
||||||
| `docs/plan-redesign.md` | Новый дизайн production UI | active |
|
| `docs/plan-redesign.md` | Новый дизайн production UI | active |
|
||||||
| `docs/plan-seo.md` | SEO/GEO-план | active |
|
| `docs/plan-seo.md` | SEO/GEO-план | active |
|
||||||
| `docs/plan-platform.md` | PWA/API/WASM-план | active |
|
| `docs/plan-platform.md` | PWA/API/WASM-план | active |
|
||||||
| `docs/checklist-manual-testing.md` | Ручная приёмка preview | active |
|
| `docs/checklist-manual-testing.md` | Ручная приёмка production UI | active |
|
||||||
| `docs/tools-map.md` | Живая карта инструментов | active |
|
| `docs/tools-map.md` | Живая карта инструментов | active |
|
||||||
| `web/eslint-plugins/README.md` | Детали custom lint rules и token policy | active |
|
| `web/eslint-plugins/README.md` | Детали custom lint rules и token policy | active |
|
||||||
| `docs/archive/README.md` | Правила архивирования и удаления старых документов | active |
|
| `docs/archive/README.md` | Правила архивирования и удаления старых документов | active |
|
||||||
@@ -50,17 +51,17 @@
|
|||||||
только если темы нет в существующем документе; иначе обновляется источник
|
только если темы нет в существующем документе; иначе обновляется источник
|
||||||
истины.
|
истины.
|
||||||
|
|
||||||
## Текущий baseline Q3
|
## Текущий baseline Q4
|
||||||
|
|
||||||
На момент фиксации Q3:
|
На момент фиксации Q4:
|
||||||
|
|
||||||
- production registry содержит 122 инструмента;
|
- production registry содержит 122 инструмента;
|
||||||
- `pnpm --dir web check` проходит без ошибок и предупреждений;
|
- `pnpm --dir web check` проходит без ошибок и предупреждений;
|
||||||
- `pnpm --dir web test` — 39 test files / 422 tests;
|
- `pnpm --dir web test` — 39 test files / 425 tests;
|
||||||
- `pnpm --dir web test:rules` — 4 test files / 74 tests;
|
- `pnpm --dir web test:rules` — 4 test files / 74 tests;
|
||||||
- `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused
|
- `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused
|
||||||
design tokens от token audit;
|
design tokens от token audit;
|
||||||
- `pnpm verify` проходит; production E2E — 105 passed, 5 skipped.
|
- `pnpm verify` проходит; production E2E — 105 passed, 4 skipped.
|
||||||
|
|
||||||
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`.
|
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ E2E запускается отдельно от быстрых проверок
|
|||||||
- Поведенческий тест проверяет пользовательский контракт, а не текущую
|
- Поведенческий тест проверяет пользовательский контракт, а не текущую
|
||||||
реализацию.
|
реализацию.
|
||||||
- Сначала предпочитать `role`, `aria-*`, label, stable id или href.
|
- Сначала предпочитать `role`, `aria-*`, label, stable id или href.
|
||||||
|
- `data-testid` ограничен source/result/verdict/empty-state; общие
|
||||||
|
locator-паттерны собраны в `web/e2e/helpers/page.ts`, policy — в
|
||||||
|
`docs/testing-strategy.md`.
|
||||||
- `data-testid` использовать только там, где семантического locator нет.
|
- `data-testid` использовать только там, где семантического locator нет.
|
||||||
- Не фиксировать абсолютное число инструментов, если проверка не связана с этим
|
- Не фиксировать абсолютное число инструментов, если проверка не связана с этим
|
||||||
инвариантом.
|
инвариантом.
|
||||||
@@ -72,7 +75,7 @@ E2E запускается отдельно от быстрых проверок
|
|||||||
- обязательное поведение агента — `AGENTS.md`;
|
- обязательное поведение агента — `AGENTS.md`;
|
||||||
- индекс и карта документов — `docs/README.md`;
|
- индекс и карта документов — `docs/README.md`;
|
||||||
- качество и точные команды — `docs/quality-gates.md`;
|
- качество и точные команды — `docs/quality-gates.md`;
|
||||||
- тестовая архитектура — `docs/testing-strategy.md` после Q4;
|
- тестовая архитектура — `docs/testing-strategy.md`;
|
||||||
- lint implementation — `web/eslint-plugins/README.md`;
|
- lint implementation — `web/eslint-plugins/README.md`;
|
||||||
- продуктовые решения — backlog и активные планы.
|
- продуктовые решения — backlog и активные планы.
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -132,10 +132,7 @@
|
|||||||
`resizeSize: "Width and/or height must be positive"`). Проверяется на
|
`resizeSize: "Width and/or height must be positive"`). Проверяется на
|
||||||
resize-png / crop-png. Ожидание: человекочитаемое сообщение на текущем языке.
|
resize-png / crop-png. Ожидание: человекочитаемое сообщение на текущем языке.
|
||||||
Fixme: `web/e2e/known-issues.spec.ts` (тест «error message localized»).
|
Fixme: `web/e2e/known-issues.spec.ts` (тест «error message localized»).
|
||||||
5. **Theme storage key в e2e устарел** — production использует ключ `theme`, а
|
5. **Reset и auto-run требуют решения** — `pipeline.spec.ts:55-63` ожидает
|
||||||
`web/e2e/navigation.spec.ts:30-48` проверяет `easy-png-tools:theme`.
|
|
||||||
Ожидание: исправить тест на фактический контракт и снять `test.fixme`.
|
|
||||||
6. **Reset и auto-run требуют решения** — `pipeline.spec.ts:55-63` ожидает
|
|
||||||
очистку результата, но текущий `SchemaToolView.svelte:162-173` может сразу
|
очистку результата, но текущий `SchemaToolView.svelte:162-173` может сразу
|
||||||
перезапустить обработку. Нужно определить семантику Reset, затем исправить
|
перезапустить обработку. Нужно определить семантику Reset, затем исправить
|
||||||
тест и снять `test.fixme`.
|
тест и снять `test.fixme`.
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Чек-лист ручного тестирования preview
|
# Чек-лист ручного тестирования production UI
|
||||||
|
|
||||||
> Вспомогательный документ к `docs/archive/plan-testing-preview.md` и e2e-тестам
|
> Вспомогательный документ к e2e-тестам (`web/e2e/`, `pnpm --dir web test:e2e`)
|
||||||
> (`web/e2e/`, `pnpm --dir web test:e2e`). Автоматизированная часть закрывает
|
> и стратегии `docs/testing-strategy.md`. Автоматизированная часть закрывает
|
||||||
> «нет падений/ошибок»; здесь — то, что руками, и что Playwright не покрывает.
|
> «нет падений/ошибок»; здесь — то, что руками, и что Playwright не покрывает.
|
||||||
>
|
>
|
||||||
> Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build
|
> Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build
|
||||||
> → `node scripts/serve-static.mjs`).
|
> → `node scripts/serve-static.mjs`).
|
||||||
>
|
>
|
||||||
> Критерий приёмки большинства пунктов: **нет зависит, нет «залипания»,
|
> Критерий приёмки большинства пунктов: **нет зависаний, нет «залипания»,
|
||||||
> сообщения об ошибках человекочитаемые**.
|
> сообщения об ошибках человекочитаемые**.
|
||||||
|
|
||||||
## A. Краевые PNG-файлы
|
## A. Краевые PNG-файлы
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
|
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
|
||||||
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
|
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
|
||||||
pipeline.spec.ts).
|
pipeline.spec.ts).
|
||||||
- [ ] PNG с битой CRCh / 16-bit — как ведут себя анализаторы (ориентация,
|
- [ ] PNG с битой CRC / 16-bit — как ведут себя анализаторы (ориентация, размер,
|
||||||
размер, прозрачность).
|
прозрачность).
|
||||||
|
|
||||||
### Файлы (`tests/fixtures/manual-edge-cases/`)
|
### Файлы (`tests/fixtures/manual-edge-cases/`)
|
||||||
|
|
||||||
@@ -59,8 +59,7 @@
|
|||||||
красного alert-текста в виде `errors.*`.
|
красного alert-текста в виде `errors.*`.
|
||||||
- [ ] Невалидные цвета в плашках/градиентах (мисс-спелл `#xyz`, короткие hex,
|
- [ ] Невалидные цвета в плашках/градиентах (мисс-спелл `#xyz`, короткие hex,
|
||||||
именные цвета) — silent repair или понятная ошибка.
|
именные цвета) — silent repair или понятная ошибка.
|
||||||
- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет
|
- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет.
|
||||||
(кейс из плана п.14).
|
|
||||||
- [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс).
|
- [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс).
|
||||||
|
|
||||||
## C. Буфер обмена и drag-n-drop
|
## C. Буфер обмена и drag-n-drop
|
||||||
@@ -111,8 +110,6 @@
|
|||||||
alert. `docs/backlog.md`, `web/e2e/known-issues.spec.ts`.
|
alert. `docs/backlog.md`, `web/e2e/known-issues.spec.ts`.
|
||||||
- [ ] **Локализация ошибок:** получить человекочитаемое сообщение на текущем
|
- [ ] **Локализация ошибок:** получить человекочитаемое сообщение на текущем
|
||||||
языке вместо сырого i18n-ключа. `docs/backlog.md`, `web/e2e/i18n.spec.ts`.
|
языке вместо сырого i18n-ключа. `docs/backlog.md`, `web/e2e/i18n.spec.ts`.
|
||||||
- [ ] **Theme persistence:** переключить тему и проверить сохранение в ключе
|
|
||||||
`theme`. `docs/backlog.md`, `web/e2e/navigation.spec.ts`.
|
|
||||||
- [ ] **Reset/auto-run:** проверить выбранную семантику Reset и автоматический
|
- [ ] **Reset/auto-run:** проверить выбранную семантику Reset и автоматический
|
||||||
перезапуск после изменения параметров. `docs/backlog.md`,
|
перезапуск после изменения параметров. `docs/backlog.md`,
|
||||||
`web/e2e/pipeline.spec.ts`.
|
`web/e2e/pipeline.spec.ts`.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# План: линтеры, тесты и качество кодовой базы
|
# План: линтеры, тесты и качество кодовой базы
|
||||||
|
|
||||||
> Статус: **draft — Q0–Q3 готовы к ревью; Q4 — следующий этап**.
|
> Статус: **draft — Q0–Q4 готовы к ревью; Q5 — следующий этап**.
|
||||||
>
|
>
|
||||||
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
|
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
|
||||||
> инфраструктурных скриптов. Q0–Q3 выполнены; Q4–Q8 выполняются отдельными
|
> инфраструктурных скриптов. Q0–Q4 выполнены; Q5–Q8 выполняются отдельными
|
||||||
> атомарными шагами.
|
> атомарными шагами.
|
||||||
>
|
>
|
||||||
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
|
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
|
||||||
@@ -152,36 +152,30 @@ pnpm --dir web test:e2e
|
|||||||
- Проверены production build, verify и отдельный E2E smoke production-маршрутов.
|
- Проверены production build, verify и отдельный E2E smoke production-маршрутов.
|
||||||
|
|
||||||
**Результат:** `/v1/**`, UI-v1-файлы и legacy-конфигурация отсутствуют;
|
**Результат:** `/v1/**`, UI-v1-файлы и legacy-конфигурация отсутствуют;
|
||||||
следующий этап — Q4.
|
следующий этап — Q5.
|
||||||
|
|
||||||
### Q4. Тестовый контракт e2e
|
### Q4. Тестовый контракт e2e
|
||||||
|
|
||||||
**Оценка:** M. **Зависимости:** Q0–Q3, решение по i18n и генераторам.
|
**Статус:** выполнен. **Проверено:** `pnpm verify` и полный Playwright smoke
|
||||||
|
проходят; 105 тестов passed, 4 known-issue теста skipped. **Оценка:** M.
|
||||||
|
**Зависимости:** Q0–Q3.
|
||||||
|
|
||||||
- Основной locator: `role`, `aria-*`, `getByLabel`, stable `id`/`href`.
|
- Добавлен `docs/testing-strategy.md` с locator, fixture, i18n и `test.fixme`
|
||||||
- `data-testid` использовать только там, где семантического API недостаточно:
|
policy.
|
||||||
source, result, empty-state, download.
|
- В production markup добавлены только разрешённые hooks для source/result/
|
||||||
- Убрать зависимость от:
|
verdict/empty-state; текстовые элементы получили accessible labels, фильтры —
|
||||||
- абсолютных чисел `TOTAL`/`GROUPS`;
|
`aria-pressed`, а каталог — семантические heading/status landmarks.
|
||||||
- точного количества fuzzy-search результатов;
|
- E2E переведён на `role`, label и stable `href`; удалены `TOTAL`/`GROUPS`,
|
||||||
- внутренних CSS-классов;
|
exact fuzzy counts, internal CSS selectors, `alt`-locators и полные verdict
|
||||||
- `img[alt="Result image"]`;
|
strings. Точные verdict-переводы перенесены в unit-тесты.
|
||||||
- полных локализованных предложений.
|
- Helpers централизованы в `web/e2e/helpers/page.ts`; исправлены stale
|
||||||
- Для каталога проверять конкретные инструменты и согласованность отображённых
|
theme/source/locale checks. Theme fixme снят.
|
||||||
карточек общему числу, а не фиксированное число `122`.
|
- Q5-фиксы resize/crop, generator, Reset и error-сценария не смешивались с этим
|
||||||
- Централизовать helpers для открытия инструмента, загрузки, результата, ошибки
|
этапом.
|
||||||
и download.
|
|
||||||
- Зафиксировать locator/i18n/fixture/fixme-политику в
|
|
||||||
`docs/testing-strategy.md`; детали конкретных тестов оставлять рядом с ними.
|
|
||||||
- В unit-тестах проверять ключи через `t()` и i18n helpers. В e2e проверять
|
|
||||||
поведение и отдельный i18n smoke; `data-i18n-key` добавлять только при
|
|
||||||
реальной необходимости тестировать ключ в DOM.
|
|
||||||
- Убрать нестандартные символы из поведенческих ожиданий (`→`, длинные тире,
|
|
||||||
`×`); названия тестов не переписывать механически без необходимости.
|
|
||||||
- Исправить stale-проверки темы, source-locator и locale assertions.
|
|
||||||
|
|
||||||
**Готово, когда:** изменение формулировки, класса или порядка элементов не
|
**Результат:** изменение формулировки, класса или порядка элементов не ломает
|
||||||
ломает поведенческий тест без изменения пользовательского контракта.
|
поведенческий тест без изменения пользовательского контракта; следующий этап —
|
||||||
|
Q5.
|
||||||
|
|
||||||
### Q5. Закрыть известные preview-баги
|
### Q5. Закрыть известные preview-баги
|
||||||
|
|
||||||
@@ -379,9 +373,9 @@ refactor, новые i18n-правила и исправление продук
|
|||||||
|
|
||||||
## 7. Решения, которые нужно принять на ревью
|
## 7. Решения, которые нужно принять на ревью
|
||||||
|
|
||||||
- Какие semantic locators и допустимые `data-testid` закрепить в Q4?
|
- Какой default выбрать для `resize-png` и `crop-png` при загрузке?
|
||||||
- Какие browser/e2e проверки должны быть обязательными для PR, а какие —
|
|
||||||
отдельным ночным прогоном?
|
|
||||||
- Подтверждаем ли auto-run генераторов без кнопки Generate?
|
- Подтверждаем ли auto-run генераторов без кнопки Generate?
|
||||||
- Что именно должен делать Reset при включённом auto-run?
|
- Что именно должен делать Reset при включённом auto-run?
|
||||||
- Какие i18n-сценарии проверяются в e2e, а какие остаются unit-проверками?
|
- Какой независимый invalid-input сценарий заменит resize-dependent error test?
|
||||||
|
- Какие browser/e2e проверки должны быть обязательными для PR, а какие —
|
||||||
|
отдельным ночным прогоном?
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ pnpm verify
|
|||||||
## E2E
|
## E2E
|
||||||
|
|
||||||
E2E не входит в `verify`, потому что Playwright поднимает build и статический
|
E2E не входит в `verify`, потому что Playwright поднимает build и статический
|
||||||
сервер:
|
сервер. Locator, fixture, i18n и `test.fixme`-политика описаны в
|
||||||
|
`docs/testing-strategy.md`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
pnpm --dir web test:e2e
|
pnpm --dir web test:e2e
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Стратегия e2e-тестирования
|
||||||
|
|
||||||
|
> Статус: **active**. Канонические правила для `web/e2e/`; продуктовые решения и
|
||||||
|
> известные баги остаются в `docs/backlog.md` и
|
||||||
|
> `docs/checklist-manual-testing.md`.
|
||||||
|
|
||||||
|
## 1. Что проверяем
|
||||||
|
|
||||||
|
E2E проверяет пользовательский контракт production UI: открытие маршрута,
|
||||||
|
доступность элемента, загрузку или ввод данных, появление результата, ошибку,
|
||||||
|
скачивание и переключение локали. Внутренняя реализация, порядок карточек,
|
||||||
|
CSS-классы и конкретный формат временного URL не являются контрактом.
|
||||||
|
|
||||||
|
Точные переводы проверяются в unit-тестах i18n. В e2e проверяется поведение и
|
||||||
|
отдельный RU/EN smoke, а не полный локализованный текст каждого verdict.
|
||||||
|
|
||||||
|
## 2. Приоритет locator-ов
|
||||||
|
|
||||||
|
1. `getByRole()` с доступным именем и `getByLabel()`.
|
||||||
|
2. Стабильные `href` или `id`, если семантического API нет.
|
||||||
|
3. Разрешённые `data-testid` только для визуальных границ, которые нельзя
|
||||||
|
надёжно назвать через accessibility API:
|
||||||
|
- `source-image`;
|
||||||
|
- `result-image`;
|
||||||
|
- `result-verdict`;
|
||||||
|
- `empty-state`.
|
||||||
|
4. `input[type="file"]` и download-button допускаются только внутри
|
||||||
|
`web/e2e/helpers/page.ts`.
|
||||||
|
|
||||||
|
Нельзя добавлять `data-testid` для счётчиков, групп, metadata, verdict текста,
|
||||||
|
кнопок темы или переключателя языка без отдельного обоснования.
|
||||||
|
|
||||||
|
## 3. Запрещённые зависимости
|
||||||
|
|
||||||
|
В e2e не фиксируются:
|
||||||
|
|
||||||
|
- абсолютные `TOTAL`, `GROUPS` и другие snapshot-числа каталога;
|
||||||
|
- точное число fuzzy-search результатов или порядок карточек;
|
||||||
|
- внутренние CSS-классы и HTML-структура, если есть role/label/href;
|
||||||
|
- `alt` как способ найти результат или источник;
|
||||||
|
- полные локализованные предложения и длинные тире в ожиданиях;
|
||||||
|
- локализованные имена контролов в общих сценариях без явной установки `en`.
|
||||||
|
|
||||||
|
Поведенческий тест может проверять, что известный инструмент видим или скрыт,
|
||||||
|
ошибка имеет текст и не является сырым i18n-ключом, результат доступен для
|
||||||
|
скачивания. Это не требует повторять словарь в e2e.
|
||||||
|
|
||||||
|
## 4. Локаль и fixtures
|
||||||
|
|
||||||
|
Обычные сценарии запускаются с `en` через `useEnglish()` или `openTool()`. RU/EN
|
||||||
|
проверяется отдельным `i18n.spec.ts`; после смены локали locator не должен
|
||||||
|
зависеть от старого accessible name.
|
||||||
|
|
||||||
|
Изображения создаются воспроизводимыми helpers в `web/e2e/helpers/fixtures.ts`.
|
||||||
|
Тесты не добавляют большие бинарные файлы и не зависят от локальных
|
||||||
|
пользовательских файлов. `toPass()` допускается только для ограниченного
|
||||||
|
ожидания hydration или асинхронного результата; он не должен превращать проверку
|
||||||
|
продукта в бесконечный retry.
|
||||||
|
|
||||||
|
## 5. Helpers и `test.fixme`
|
||||||
|
|
||||||
|
`web/e2e/helpers/page.ts` владеет общими операциями `openTool`, `uploadImage`,
|
||||||
|
`resultImage`, `sourceImage`, `verdictStatus`, `textResult`, `errorAlert`,
|
||||||
|
`emptyState`, `rangeInput` и `downloadResult`. Специфика должна вызывать эти
|
||||||
|
helpers, а не повторять raw-селекторы.
|
||||||
|
|
||||||
|
`test.fixme` допускается только для зарегистрированной проблемы в backlog или
|
||||||
|
checklist. В комментарии указывается ссылка на неё; Q4 не исправляет продуктовое
|
||||||
|
поведение. После исправления тест и ссылка обновляются вместе.
|
||||||
|
|
||||||
|
## 6. Проверки
|
||||||
|
|
||||||
|
Для изменений e2e выполняются:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm --dir web check
|
||||||
|
pnpm --dir web test:rules
|
||||||
|
pnpm --dir web lint:all
|
||||||
|
pnpm --dir web exec prettier --check .
|
||||||
|
pnpm check:docs
|
||||||
|
pnpm --dir web test:e2e
|
||||||
|
pnpm verify
|
||||||
|
```
|
||||||
|
|
||||||
|
`verify` не включает Playwright: E2E остаётся отдельным тяжёлым gate. В CI
|
||||||
|
non-blocking E2E не считается заменой локальному smoke; перед ревью фиксируются
|
||||||
|
число passed, skipped и известные fixme.
|
||||||
+28
-33
@@ -1,55 +1,50 @@
|
|||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
import { expectNoErrors, trackErrors } from "./helpers/page";
|
import {
|
||||||
|
expectNoErrors,
|
||||||
|
toolLink,
|
||||||
|
trackErrors,
|
||||||
|
useEnglish,
|
||||||
|
} from "./helpers/page";
|
||||||
|
|
||||||
const TOTAL = 122;
|
test.beforeEach(async ({ page }) => {
|
||||||
const GROUPS = 8;
|
await useEnglish(page);
|
||||||
|
});
|
||||||
|
|
||||||
test("catalog shows total and all groups", async ({ page }) => {
|
test("catalog exposes a total and category headings", async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await page.goto("/list-tools");
|
await page.goto("/list-tools");
|
||||||
await expect(page.locator(".catalog-total b")).toHaveText(String(TOTAL));
|
const total = page.getByRole("status", { name: /tools available/i });
|
||||||
await expect(page.locator(".catalog-group")).toHaveCount(GROUPS);
|
await expect(total).toBeVisible();
|
||||||
|
await expect(total).toHaveAttribute("aria-label", /^\d+ /);
|
||||||
|
await expect(page.getByRole("heading", { level: 2 }).first()).toBeVisible();
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("catalog search narrows results", async ({ page }) => {
|
test("catalog search narrows results", async ({ page }) => {
|
||||||
await page.goto("/list-tools");
|
await page.goto("/list-tools");
|
||||||
await expect(async () => {
|
|
||||||
await page.getByRole("textbox", { name: "Search tools" }).fill("resize");
|
await page.getByRole("textbox", { name: "Search tools" }).fill("resize");
|
||||||
await expect(page.locator(".tool-card")).toHaveCount(1);
|
await expect(toolLink(page, "resize-png")).toBeVisible();
|
||||||
}).toPass();
|
await expect(toolLink(page, "flip-png")).toBeHidden();
|
||||||
await expect(page.locator(".tool-card").first()).toContainText("Resize PNG");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("category filter shows only matching group and resets on ALL", async ({
|
test("category filter narrows the catalog and resets", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/list-tools");
|
await page.goto("/list-tools");
|
||||||
const allButtons = page.locator(".tool-card");
|
const convert = page.getByRole("button", { name: /convert/i, exact: true });
|
||||||
const allCount = await allButtons.count();
|
const all = page.getByRole("button", { name: /^all$/i });
|
||||||
expect(allCount).toBe(TOTAL);
|
|
||||||
|
|
||||||
await expect(async () => {
|
await convert.click();
|
||||||
await page.getByRole("button", { name: "CONVERT" }).click();
|
await expect(convert).toHaveAttribute("aria-pressed", "true");
|
||||||
await expect(page.locator(".catalog-group")).toHaveCount(1);
|
await expect(toolLink(page, "convert-png-to-jpg")).toBeVisible();
|
||||||
}).toPass();
|
await expect(toolLink(page, "flip-png")).toBeHidden();
|
||||||
await expect(page.locator(".catalog-group")).toContainText("Convert");
|
|
||||||
const convertCards = await page.locator(".tool-card").count();
|
|
||||||
expect(convertCards).toBeGreaterThan(0);
|
|
||||||
expect(convertCards).toBeLessThan(allCount);
|
|
||||||
|
|
||||||
await expect(async () => {
|
await all.click();
|
||||||
await page.getByRole("button", { name: "ALL" }).click();
|
await expect(all).toHaveAttribute("aria-pressed", "true");
|
||||||
await expect(page.locator(".catalog-group")).toHaveCount(GROUPS);
|
await expect(toolLink(page, "flip-png")).toBeVisible();
|
||||||
}).toPass();
|
|
||||||
await expect(page.locator(".tool-card")).toHaveCount(allCount);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("workspace search matches a tool by title", async ({ page }) => {
|
test("workspace search matches a tool by title", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(async () => {
|
|
||||||
await page.getByRole("textbox", { name: "Search tools" }).fill("flip");
|
await page.getByRole("textbox", { name: "Search tools" }).fill("flip");
|
||||||
await expect(page.locator(".tool-card")).toHaveCount(1);
|
await expect(toolLink(page, "flip-png")).toBeVisible();
|
||||||
}).toPass();
|
await expect(toolLink(page, "resize-png")).toBeHidden();
|
||||||
await expect(page.locator(".tool-card").first()).toContainText("Flip PNG");
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
import { expectNoErrors, openTool, trackErrors } from "./helpers/page";
|
import {
|
||||||
|
expectNoErrors,
|
||||||
|
openTool,
|
||||||
|
resultImage,
|
||||||
|
trackErrors,
|
||||||
|
} from "./helpers/page";
|
||||||
|
|
||||||
test.describe("generators — known UI gap (#checklist, п.1)", () => {
|
test.describe("generators — known UI gap (#checklist, п.1)", () => {
|
||||||
// FIXME: Генераторы (21/121) открываются, но в UI нет ни полей схемы, ни кнопки
|
// FIXME: Генераторы открываются, но в UI нет ни полей схемы, ни кнопки
|
||||||
// «Generate» — только RU/EN/Reset. Пока баг открыт — fixme с ожидаемым
|
// «Generate» — только RU/EN/Reset. Задача Q5: `docs/backlog.md`,
|
||||||
// сценарием; после фикса убрать fixme.
|
// `docs/checklist-manual-testing.md`. После фикса убрать fixme.
|
||||||
test.fixme("single-color-png renders Generate controls and produces a result", async ({
|
test.fixme("single-color-png renders Generate controls and produces a result", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -12,13 +17,13 @@ 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 image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("create-empty-png page opens without errors", async ({ page }) => {
|
test("create-empty-png page opens without errors", async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "create-empty-png");
|
await openTool(page, "create-empty-png");
|
||||||
await expect(page.locator("h1")).toContainText("Create");
|
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+56
-16
@@ -1,6 +1,13 @@
|
|||||||
import { expect, type Page } from "playwright/test";
|
import { expect, type Locator, type Page } from "playwright/test";
|
||||||
import type { SourceFile } from "./fixtures";
|
import type { SourceFile } from "./fixtures";
|
||||||
|
|
||||||
|
export const TEST_IDS = {
|
||||||
|
source: "source-image",
|
||||||
|
result: "result-image",
|
||||||
|
verdict: "result-verdict",
|
||||||
|
empty: "empty-state",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export type ErrorSink = { errors: string[] };
|
export type ErrorSink = { errors: string[] };
|
||||||
|
|
||||||
export function trackErrors(page: Page): ErrorSink {
|
export function trackErrors(page: Page): ErrorSink {
|
||||||
@@ -12,39 +19,72 @@ export function trackErrors(page: Page): ErrorSink {
|
|||||||
return { errors };
|
return { errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function useEnglish(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem("locale", "en");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function openTool(page: Page, id: string): Promise<void> {
|
export async function openTool(page: Page, id: string): Promise<void> {
|
||||||
|
await useEnglish(page);
|
||||||
await page.goto(`/tools/${id}`);
|
await page.goto(`/tools/${id}`);
|
||||||
await expect(page.locator(".schema-tool h1")).toBeVisible();
|
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toolLink(page: Page, id: string): Locator {
|
||||||
|
return page.locator(`a[href="/tools/${id}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resultImage(page: Page): Locator {
|
||||||
|
return page.getByTestId(TEST_IDS.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceImage(page: Page): Locator {
|
||||||
|
return page.getByTestId(TEST_IDS.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verdictStatus(page: Page): Locator {
|
||||||
|
return page.getByTestId(TEST_IDS.verdict);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function textResult(page: Page): Locator {
|
||||||
|
return page.getByLabel("Text result");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorAlert(page: Page): Locator {
|
||||||
|
return page.getByRole("alert");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyState(page: Page): Locator {
|
||||||
|
return page.getByTestId(TEST_IDS.empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rangeInput(page: Page, name: RegExp | string): Locator {
|
||||||
|
return page.getByRole("slider", { name });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
|
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
|
||||||
await page.locator('.actions input[type="file"]').setInputFiles({
|
const input = page.locator('input[type="file"]');
|
||||||
|
await expect(input).toHaveCount(1);
|
||||||
|
await input.setInputFiles({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
buffer: file.buffer,
|
buffer: file.buffer,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function metaValue(page: Page, caption: string): Promise<string> {
|
export async function downloadResult(page: Page): Promise<string> {
|
||||||
const row = page.locator(".meta-row", { hasText: caption });
|
const button = page.getByRole("button", { name: "Download result" });
|
||||||
await expect(row).toBeVisible();
|
await expect(button).toBeVisible();
|
||||||
const value = await row.locator(".meta-value").textContent();
|
|
||||||
return value?.trim() ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function suggestedDownloadName(
|
|
||||||
page: Page,
|
|
||||||
selector: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent("download"),
|
page.waitForEvent("download"),
|
||||||
page.locator(selector).click(),
|
button.click(),
|
||||||
]);
|
]);
|
||||||
return download.suggestedFilename();
|
return download.suggestedFilename();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectNoErrorAlert(page: Page): Promise<void> {
|
export async function expectNoErrorAlert(page: Page): Promise<void> {
|
||||||
await expect(page.locator('[role="alert"]')).toHaveCount(0);
|
await expect(errorAlert(page)).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectNoErrors(sink: ErrorSink): Promise<void> {
|
export async function expectNoErrors(sink: ErrorSink): Promise<void> {
|
||||||
|
|||||||
+10
-17
@@ -1,13 +1,14 @@
|
|||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
import { opaquePng } from "./helpers/fixtures";
|
import { opaquePng } from "./helpers/fixtures";
|
||||||
import { openTool, uploadImage } from "./helpers/page";
|
import { openTool, toolLink, uploadImage, useEnglish } from "./helpers/page";
|
||||||
|
|
||||||
test("поиск каталога находит по названию из другой локали", async ({
|
test("поиск каталога находит по названию из другой локали", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
await useEnglish(page);
|
||||||
await page.goto("/list-tools");
|
await page.goto("/list-tools");
|
||||||
const input = page.locator(".catalog-search input");
|
const input = page.getByRole("textbox");
|
||||||
const flipCard = page.locator('a.tool-card[href*="flip-png"]');
|
const flipCard = toolLink(page, "flip-png");
|
||||||
await input.fill("отразить");
|
await input.fill("отразить");
|
||||||
await expect(flipCard).toContainText("Flip PNG");
|
await expect(flipCard).toContainText("Flip PNG");
|
||||||
await page.getByRole("button", { name: "RU", exact: true }).click();
|
await page.getByRole("button", { name: "RU", exact: true }).click();
|
||||||
@@ -22,27 +23,19 @@ test("RU/EN: переключение переводит заголовок ин
|
|||||||
await openTool(page, "resize-png");
|
await openTool(page, "resize-png");
|
||||||
const ruBtn = page.getByRole("button", { name: "RU", exact: true });
|
const ruBtn = page.getByRole("button", { name: "RU", exact: true });
|
||||||
const enBtn = page.getByRole("button", { name: "EN", exact: true });
|
const enBtn = page.getByRole("button", { name: "EN", exact: true });
|
||||||
await expect(async () => {
|
const heading = page.getByRole("heading", { level: 1 });
|
||||||
await ruBtn.click();
|
await ruBtn.click();
|
||||||
await expect(page.locator(".schema-tool h1")).toHaveText(
|
await expect(heading).toHaveText("Изменить размер PNG");
|
||||||
"Изменить размер PNG",
|
|
||||||
);
|
|
||||||
}).toPass();
|
|
||||||
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
|
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
|
||||||
await enBtn.click();
|
await enBtn.click();
|
||||||
await expect(page.locator(".schema-tool h1")).toHaveText("Resize PNG");
|
await expect(heading).toHaveText("Resize PNG");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Валится на известном баге resize-png (дефолтные 0-параметры дают ошибку при
|
|
||||||
// аплоаде, см. known-issues.spec.ts). Здесь он используется как детерминированная
|
|
||||||
// ошибка: важно, что её текст локализован, а не сырой ключ.
|
|
||||||
// TODO: найти другую ошибку, эта будет исправлена при фиксе resize-png
|
|
||||||
test("ошибка run'а локализуется, не сырой i18n-ключ", async ({ page }) => {
|
test("ошибка run'а локализуется, не сырой i18n-ключ", async ({ page }) => {
|
||||||
await openTool(page, "resize-png");
|
await openTool(page, "resize-png");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
const alert = page.locator("[role='alert']");
|
const alert = page.getByRole("alert");
|
||||||
await expect(alert).toBeVisible();
|
await expect(alert).toBeVisible();
|
||||||
const text = (await alert.textContent()) ?? "";
|
await expect(alert).toHaveText(/\S/);
|
||||||
expect(text).not.toMatch(/^errors\./);
|
await expect(alert).not.toHaveText(/^errors\./);
|
||||||
expect(text.length).toBeGreaterThan(3);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
import { opaquePng } from "./helpers/fixtures";
|
import { opaquePng } from "./helpers/fixtures";
|
||||||
import { openTool, uploadImage } from "./helpers/page";
|
import { openTool, resultImage, uploadImage } from "./helpers/page";
|
||||||
|
|
||||||
// FIXME: В файле зарегистрированы баги preview (см. docs/checklist-manual-testing.md
|
// FIXME: В файле зарегистрированы баги production UI (см.
|
||||||
// → «Известные баги preview»). Тела assert'ят ОЖИДАЕМОЕ поведение. Статус fixme
|
// `docs/backlog.md` и `docs/checklist-manual-testing.md`, раздел G). Тела
|
||||||
// означает «мы знаем, что сейчас падает»; когда баг починят — убрать fixme и
|
// assert'ят ОЖИДАЕМОЕ поведение. Статус 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,8 +14,8 @@ 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.getByRole("alert")).toHaveCount(0);
|
||||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
await expect(resultImage(page)).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,7 +23,7 @@ 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.getByRole("alert")).toHaveCount(0);
|
||||||
await expect(page.locator('img[alt="Result image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+26
-29
@@ -1,14 +1,18 @@
|
|||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
import { expectNoErrors, trackErrors } from "./helpers/page";
|
import { expectNoErrors, trackErrors, useEnglish } from "./helpers/page";
|
||||||
|
|
||||||
const ROUTES = ["/", "/list-tools", "/kit", "/tools/flip-png"];
|
const ROUTES = ["/", "/list-tools", "/kit", "/tools/flip-png"];
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await useEnglish(page);
|
||||||
|
});
|
||||||
|
|
||||||
for (const route of ROUTES) {
|
for (const route of ROUTES) {
|
||||||
test(`page ${route} loads without errors`, async ({ page }) => {
|
test(`page ${route} loads without errors`, async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
const resp = await page.goto(route);
|
const resp = await page.goto(route);
|
||||||
expect(resp?.status()).toBe(200);
|
expect(resp?.status()).toBe(200);
|
||||||
await page.waitForLoadState("networkidle");
|
await expect(page.getByRole("main")).toBeVisible();
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -18,40 +22,33 @@ test("unknown tool route responds 404 on static build", async ({ page }) => {
|
|||||||
expect(resp?.status()).toBe(404);
|
expect(resp?.status()).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// FIXME:
|
test("theme toggle persists the selected theme", async ({ page }) => {
|
||||||
// Error: expect(received).toBe(expected) // Object.is equality
|
const sink = trackErrors(page);
|
||||||
|
|
||||||
// Expected: "dark"
|
|
||||||
// Received: null
|
|
||||||
|
|
||||||
// 36 | expect(["light", "dark"]).toContain(before);
|
|
||||||
// 37 | expect(["light", "dark"]).toContain(after);
|
|
||||||
// > 38 | expect(stored).toBe(after);
|
|
||||||
test.fixme("theme toggle flips preview theme and persists to localStorage", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
const root = page.locator("main.preview-root");
|
const main = page.getByRole("main");
|
||||||
const before = await root.getAttribute("data-theme");
|
const before = await main.getAttribute("data-theme");
|
||||||
await expect(async () => {
|
|
||||||
await page.getByRole("button", { name: "Toggle theme" }).click();
|
|
||||||
const after = await root.getAttribute("data-theme");
|
|
||||||
expect(after).not.toBe(before);
|
|
||||||
}).toPass();
|
|
||||||
const after = await root.getAttribute("data-theme");
|
|
||||||
const stored = await page.evaluate(() =>
|
|
||||||
localStorage.getItem("easy-png-tools:theme"),
|
|
||||||
);
|
|
||||||
expect(["light", "dark"]).toContain(before);
|
expect(["light", "dark"]).toContain(before);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Toggle theme" }).click();
|
||||||
|
await expect(main).not.toHaveAttribute("data-theme", before ?? "");
|
||||||
|
const after = await main.getAttribute("data-theme");
|
||||||
|
const stored = await page.evaluate(() => localStorage.getItem("theme"));
|
||||||
expect(["light", "dark"]).toContain(after);
|
expect(["light", "dark"]).toContain(after);
|
||||||
expect(stored).toBe(after);
|
expect(stored).toBe(after);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(main).toHaveAttribute("data-theme", after ?? "");
|
||||||
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("language toggle marks the active button", async ({ page }) => {
|
test("language toggle marks the active button", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
const group = page.getByRole("group", { name: "Language" });
|
const group = page.getByRole("group", { name: "Language" });
|
||||||
await expect(async () => {
|
const en = group.getByRole("button", { name: "EN" });
|
||||||
await group.getByRole("button", { name: "EN" }).click();
|
await en.click();
|
||||||
await expect(page.locator(".lang-btn.active")).toHaveText("EN");
|
await expect(en).toHaveAttribute("aria-pressed", "true");
|
||||||
}).toPass();
|
await expect(group.getByRole("button", { name: "RU" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+28
-42
@@ -7,33 +7,26 @@ import {
|
|||||||
opaquePng,
|
opaquePng,
|
||||||
} from "./helpers/fixtures";
|
} from "./helpers/fixtures";
|
||||||
import {
|
import {
|
||||||
|
downloadResult,
|
||||||
|
emptyState,
|
||||||
|
errorAlert,
|
||||||
expectNoErrorAlert,
|
expectNoErrorAlert,
|
||||||
expectNoErrors,
|
expectNoErrors,
|
||||||
metaValue,
|
|
||||||
openTool,
|
openTool,
|
||||||
suggestedDownloadName,
|
rangeInput,
|
||||||
|
resultImage,
|
||||||
|
sourceImage,
|
||||||
trackErrors,
|
trackErrors,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
|
useEnglish,
|
||||||
} from "./helpers/page";
|
} from "./helpers/page";
|
||||||
|
|
||||||
test("flip-png: full flow - upload, result, meta, download", async ({
|
test("flip-png: full flow - upload, result, 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 image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
await expect(page.locator('img[alt="Result image"]')).toHaveAttribute(
|
expect(await downloadResult(page)).toBe("flip-png.png");
|
||||||
"src",
|
|
||||||
/^(blob:|data:image\/png;base64,)/,
|
|
||||||
);
|
|
||||||
const resultMeta = await metaValue(page, "RESULT");
|
|
||||||
expect(resultMeta).toBe("64 × 16 px");
|
|
||||||
const downloadName = await suggestedDownloadName(
|
|
||||||
page,
|
|
||||||
'button[aria-label="Download result"]',
|
|
||||||
);
|
|
||||||
expect(downloadName).toBe("flip-png.png");
|
|
||||||
await expectNoErrorAlert(page);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
@@ -42,12 +35,8 @@ 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 image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
const downloadName = await suggestedDownloadName(
|
expect(await downloadResult(page)).toBe("convert-png-to-jpg.jpg");
|
||||||
page,
|
|
||||||
'button[aria-label="Download result"]',
|
|
||||||
);
|
|
||||||
expect(downloadName).toBe("convert-png-to-jpg.jpg");
|
|
||||||
await expectNoErrorAlert(page);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
@@ -55,31 +44,25 @@ test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
|
|||||||
test.fixme("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 image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Reset" }).click();
|
await page.getByRole("button", { name: "Reset" }).click();
|
||||||
await expect(page.locator('img[alt="Result image"]')).toHaveCount(0);
|
await expect(resultImage(page)).toHaveCount(0);
|
||||||
await expect(page.locator(".empty")).toContainText("no result yet");
|
await expect(emptyState(page)).toBeVisible();
|
||||||
await expect(page.locator('img[alt="source"]')).toBeVisible();
|
await expect(sourceImage(page)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
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 image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
const src1 = await page
|
const firstSource = await resultImage(page).getAttribute("src");
|
||||||
.locator('img[alt="Result image"]')
|
await rangeInput(page, /radius/i).fill("20");
|
||||||
.getAttribute("src");
|
await expect(resultImage(page)).not.toHaveAttribute("src", firstSource ?? "");
|
||||||
await expect(async () => {
|
|
||||||
await page.locator('input[type="range"]').fill("20");
|
|
||||||
await expect(page.locator('img[alt="Result image"]')).not.toHaveAttribute(
|
|
||||||
"src",
|
|
||||||
src1 ?? "",
|
|
||||||
);
|
|
||||||
}).toPass();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("runs without Web Worker (fallback)", async ({ browser }) => {
|
test("runs without Web Worker (fallback)", async ({ browser }) => {
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
|
try {
|
||||||
await context.addInitScript(() => {
|
await context.addInitScript(() => {
|
||||||
Object.defineProperty(window, "Worker", {
|
Object.defineProperty(window, "Worker", {
|
||||||
value: undefined,
|
value: undefined,
|
||||||
@@ -87,21 +70,24 @@ test("runs without Web Worker (fallback)", async ({ browser }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
await useEnglish(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 image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
await expectNoErrorAlert(page);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
|
} finally {
|
||||||
await context.close();
|
await context.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("invalid file upload shows error, no crash", async ({ page }) => {
|
test("invalid file upload shows error, no crash", async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(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(errorAlert(page)).toBeVisible();
|
||||||
await expect(page.locator('img[alt="Result image"]')).toHaveCount(0);
|
await expect(resultImage(page)).toHaveCount(0);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +96,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 image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,18 @@ import {
|
|||||||
expectNoErrorAlert,
|
expectNoErrorAlert,
|
||||||
expectNoErrors,
|
expectNoErrors,
|
||||||
openTool,
|
openTool,
|
||||||
|
resultImage,
|
||||||
trackErrors,
|
trackErrors,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
|
verdictStatus,
|
||||||
} from "./helpers/page";
|
} from "./helpers/page";
|
||||||
|
|
||||||
|
async function expectVerdict(page: Parameters<typeof verdictStatus>[0]) {
|
||||||
|
const verdict = verdictStatus(page);
|
||||||
|
await expect(verdict).toBeVisible();
|
||||||
|
await expect(verdict).toHaveText(/\S/);
|
||||||
|
}
|
||||||
|
|
||||||
for (const [id, input] of [
|
for (const [id, input] of [
|
||||||
["base64-to-png", tinyBase64],
|
["base64-to-png", tinyBase64],
|
||||||
["hex-to-png", "ff0000ff"],
|
["hex-to-png", "ff0000ff"],
|
||||||
@@ -26,11 +34,11 @@ for (const [id, input] of [
|
|||||||
] as const) {
|
] as const) {
|
||||||
test(`text input → ${id} produces result image`, async ({ page }) => {
|
test(`text input → ${id} produces result image`, async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await expect(async () => {
|
|
||||||
await openTool(page, id);
|
await openTool(page, id);
|
||||||
await page.locator(".text-source textarea").fill(input);
|
await expect(async () => {
|
||||||
|
await page.getByRole("textbox", { name: "Text data" }).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 image"]')).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
}).toPass({ timeout: 25_000 });
|
}).toPass({ timeout: 25_000 });
|
||||||
await expectNoErrorAlert(page);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
@@ -41,62 +49,61 @@ test("png-to-base64 shows decoded text result", async ({ page }) => {
|
|||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "png-to-base64");
|
await openTool(page, "png-to-base64");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
const code = page.locator(".result-pre code");
|
const output = page.getByLabel("Text result");
|
||||||
await expect(code).toBeVisible();
|
await expect(output).toBeVisible();
|
||||||
const text = (await code.textContent()) ?? "";
|
await expect(output).toHaveText(/\S/);
|
||||||
expect(text.length).toBeGreaterThan(20);
|
|
||||||
await expectNoErrorAlert(page);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [input, expected] of [
|
test("verify-is-png renders different verdicts for valid and invalid input", async ({
|
||||||
[tinyBase64, "Yes — this is a valid PNG signature."],
|
page,
|
||||||
["aGVsbG8=", "No — the signature does not match a PNG file."],
|
}) => {
|
||||||
] as const) {
|
|
||||||
test(`verify-is-png verdict: ${expected}`, async ({ page }) => {
|
|
||||||
await expect(async () => {
|
|
||||||
await openTool(page, "verify-is-png");
|
await openTool(page, "verify-is-png");
|
||||||
await page.locator(".text-source textarea").fill(input);
|
const input = page.getByRole("textbox", { name: "Text data" });
|
||||||
await page.getByRole("button", { name: "Render text" }).click();
|
const render = page.getByRole("button", { name: "Render text" });
|
||||||
await expect(page.locator(".verdict-text")).toContainText(expected);
|
const verdict = verdictStatus(page);
|
||||||
}).toPass({ timeout: 25_000 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("png-is-transparent: opaque image → 'No'", async ({ page }) => {
|
await input.fill(tinyBase64);
|
||||||
|
await render.click();
|
||||||
|
await expect(verdict).toBeVisible();
|
||||||
|
const validText = await verdict.textContent();
|
||||||
|
|
||||||
|
await input.fill("aGVsbG8=");
|
||||||
|
await render.click();
|
||||||
|
await expect(verdict).toBeVisible();
|
||||||
|
const invalidText = await verdict.textContent();
|
||||||
|
expect(invalidText).not.toBe(validText);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("png-is-transparent: opaque image renders a verdict", 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 expectVerdict(page);
|
||||||
"No — all pixels are fully opaque.",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("png-is-transparent: transparent image → 'Yes'", async ({ page }) => {
|
test("png-is-transparent: transparent image renders a verdict", 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 expectVerdict(page);
|
||||||
"Yes — there are transparent or semi-transparent pixels.",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("png-is-grayscale: colored image → 'No'", async ({ page }) => {
|
test("png-is-grayscale: colored image renders a verdict", 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 expectVerdict(page);
|
||||||
"No — colored pixels were found.",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("png-orientation: landscape image → Landscape", async ({ page }) => {
|
test("png-orientation: landscape image renders a verdict", async ({ page }) => {
|
||||||
await openTool(page, "png-orientation");
|
await openTool(page, "png-orientation");
|
||||||
await uploadImage(page, opaquePng); // 64×48 → landscape
|
await uploadImage(page, opaquePng);
|
||||||
await expect(page.locator(".verdict-text")).toContainText("Landscape");
|
await expectVerdict(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("png-file-size: returns a size in KB", async ({ page }) => {
|
test("png-file-size: renders a verdict", async ({ page }) => {
|
||||||
await openTool(page, "png-file-size");
|
await openTool(page, "png-file-size");
|
||||||
await uploadImage(page, opaquePng);
|
await uploadImage(page, opaquePng);
|
||||||
const verdict = page.locator(".verdict-text");
|
await expectVerdict(page);
|
||||||
await expect(verdict).toBeVisible();
|
|
||||||
await expect(verdict).toContainText("KB");
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
expectNoErrorAlert,
|
expectNoErrorAlert,
|
||||||
expectNoErrors,
|
expectNoErrors,
|
||||||
openTool,
|
openTool,
|
||||||
|
resultImage,
|
||||||
trackErrors,
|
trackErrors,
|
||||||
|
textResult,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
|
verdictStatus,
|
||||||
} from "./helpers/page";
|
} from "./helpers/page";
|
||||||
|
|
||||||
type Kind = "image" | "text-out" | "verdict";
|
type Kind = "image" | "text-out" | "verdict";
|
||||||
@@ -98,14 +101,14 @@ test.describe("smoke: tools produce output without errors", () => {
|
|||||||
test(`tool ${id} (${kind})`, async ({ page }) => {
|
test(`tool ${id} (${kind})`, async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
|
|
||||||
const outputLocator = (): ReturnType<typeof page.locator> => {
|
const outputLocator = () => {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "image":
|
case "image":
|
||||||
return page.locator('img[alt="Result image"]');
|
return resultImage(page);
|
||||||
case "text-out":
|
case "text-out":
|
||||||
return page.locator(".result-pre code");
|
return textResult(page);
|
||||||
case "verdict":
|
case "verdict":
|
||||||
return page.locator(".verdict-text");
|
return verdictStatus(page);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<section class="catalog-group">
|
<section class="catalog-group">
|
||||||
<div class="group-title">
|
<div class="group-title">
|
||||||
<span>{label}</span>
|
<h2>{label}</h2>
|
||||||
<i>{count.toString().padStart(2, "0")} <!-- --> {t("catalog.toolsCount")}</i
|
<i>{count.toString().padStart(2, "0")} <!-- --> {t("catalog.toolsCount")}</i
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,7 +35,8 @@
|
|||||||
padding-bottom: var(--space-m);
|
padding-bottom: var(--space-m);
|
||||||
border-bottom: var(--size-border) solid var(--color-border);
|
border-bottom: var(--size-border) solid var(--color-border);
|
||||||
}
|
}
|
||||||
.group-title span {
|
.group-title h2 {
|
||||||
|
margin: 0;
|
||||||
font: var(--font-size-s) var(--font-mono);
|
font: var(--font-size-s) var(--font-mono);
|
||||||
letter-spacing: var(--space-text-xl);
|
letter-spacing: var(--space-text-xl);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -12,10 +12,13 @@
|
|||||||
<h1>{t("catalog.heading")}</h1>
|
<h1>{t("catalog.heading")}</h1>
|
||||||
<p>{t("catalog.lead")}</p>
|
<p>{t("catalog.lead")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="catalog-total">
|
<output
|
||||||
|
class="catalog-total"
|
||||||
|
aria-label={`${total} ${t("catalog.toolsAvailable")}`}
|
||||||
|
>
|
||||||
<b>{total}</b>
|
<b>{total}</b>
|
||||||
<span>{t("catalog.toolsAvailable")}</span>
|
<span>{t("catalog.toolsAvailable")}</span>
|
||||||
</div>
|
</output>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={category === f.value}
|
class:active={category === f.value}
|
||||||
|
aria-pressed={category === f.value}
|
||||||
onclick={() => (category = f.value)}
|
onclick={() => (category = f.value)}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
|
|||||||
@@ -82,11 +82,17 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if resultKind === "image" && resultUrl}
|
{:else if resultKind === "image" && resultUrl}
|
||||||
<img src={resultUrl} alt={t("resultCard.alt")} />
|
<img
|
||||||
|
data-testid="result-image"
|
||||||
|
src={resultUrl}
|
||||||
|
alt={t("resultCard.alt")}
|
||||||
|
/>
|
||||||
{:else if running}
|
{:else if running}
|
||||||
<Check size={22} />
|
<Check size={22} />
|
||||||
{:else}
|
{:else}
|
||||||
<span class="empty">{t("resultCard.noResult")}</span>
|
<span data-testid="empty-state" class="empty"
|
||||||
|
>{t("resultCard.noResult")}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</PreviewTile>
|
</PreviewTile>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if mode === "image" && sourceUrl}
|
{:else if mode === "image" && sourceUrl}
|
||||||
<img src={sourceUrl} alt={t("sourceCard.alt")} />
|
<img data-testid="source-image" src={sourceUrl} alt={t("sourceCard.alt")} />
|
||||||
{:else if mode === "image"}
|
{:else if mode === "image"}
|
||||||
<span class="empty">{t("sourceCard.chooseImage")}</span>
|
<span class="empty">{t("sourceCard.chooseImage")}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -32,7 +32,11 @@
|
|||||||
{#if kind === "verdict"}
|
{#if kind === "verdict"}
|
||||||
<div class="verdict">
|
<div class="verdict">
|
||||||
<span class="verdict-label">{t("resultCard.result")}</span>
|
<span class="verdict-label">{t("resultCard.result")}</span>
|
||||||
<div class="badge badge-tone--{tone}" role="status">
|
<div
|
||||||
|
data-testid="result-verdict"
|
||||||
|
class="badge badge-tone--{tone}"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span class="verdict-text">{displayValue}</span>
|
<span class="verdict-text">{displayValue}</span>
|
||||||
<IconButton icon={Copy} label={t("textResult.copy")} onclick={oncopy} />
|
<IconButton icon={Copy} label={t("textResult.copy")} onclick={oncopy} />
|
||||||
</div>
|
</div>
|
||||||
@@ -50,7 +54,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre class="result-pre"><code>{value}</code></pre>
|
<pre aria-label={t("textResult.outputAria")} class="result-pre"><code
|
||||||
|
>{value}</code
|
||||||
|
></pre>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
<div class="text-source">
|
<div class="text-source">
|
||||||
<textarea
|
<textarea
|
||||||
|
aria-label={t("textInput.aria")}
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
{placeholder}
|
{placeholder}
|
||||||
rows="6"
|
rows="6"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<output>{current}</output>
|
<output>{current}</output>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
|
aria-label={label}
|
||||||
type="range"
|
type="range"
|
||||||
min={sp.min ?? 0}
|
min={sp.min ?? 0}
|
||||||
max={sp.max ?? 100}
|
max={sp.max ?? 100}
|
||||||
|
|||||||
@@ -77,4 +77,33 @@ describe("verdictText (вердикт с vars)", () => {
|
|||||||
it("без vars возвращает ключ как есть при отсутствии в словаре", () => {
|
it("без vars возвращает ключ как есть при отсутствии в словаре", () => {
|
||||||
expect(verdictText("png-file-size", "нет-такого")).toBe("нет-такого");
|
expect(verdictText("png-file-size", "нет-такого")).toBe("нет-такого");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"verify-is-png",
|
||||||
|
"verifyYes",
|
||||||
|
"Да — сигнатура настоящего PNG.",
|
||||||
|
"Yes — this is a valid PNG signature.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"png-is-transparent",
|
||||||
|
"transparentNo",
|
||||||
|
"Нет — все пиксели полностью непрозрачны.",
|
||||||
|
"No — all pixels are fully opaque.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"png-orientation",
|
||||||
|
"orientationLandscape",
|
||||||
|
"Ландшафт — ширина больше высоты.",
|
||||||
|
"Landscape — width is greater than height.",
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"переводит verdict %s/%s в обеих локалях",
|
||||||
|
(toolId, key, ruText, enText) => {
|
||||||
|
setLocale("ru");
|
||||||
|
expect(verdictText(toolId, key)).toBe(ruText);
|
||||||
|
setLocale("en");
|
||||||
|
expect(verdictText(toolId, key)).toBe(enText);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user