From 712fd3cb2e03b666ce7ac6e209a2729af7d299d4 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 26 Sep 2026 03:33:09 +0500 Subject: [PATCH] test: add download mime, quality verification --- docs/README.md | 8 ++-- docs/backlog.md | 4 +- docs/checklist-manual-testing.md | 8 ++-- docs/plan-codebase-quality.md | 21 +++++++-- docs/testing-strategy.md | 8 ++-- web/e2e/helpers/page.ts | 13 ++++++ web/e2e/pipeline.spec.ts | 63 ++++++++++++++++++++++----- web/src/lib/core/io.test.ts | 36 ++++++++++++++- web/src/lib/core/io.ts | 19 +++++--- web/src/lib/registry/registry.test.ts | 34 +++++++++++++++ 10 files changed, 181 insertions(+), 33 deletions(-) diff --git a/docs/README.md b/docs/README.md index 2957327..c8097bc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,17 +51,17 @@ только если темы нет в существующем документе; иначе обновляется источник истины. -## Текущий baseline Q6b +## Текущий baseline Q6c -На момент фиксации Q6b: +На момент фиксации Q6c: - production registry содержит 122 инструмента; - `pnpm --dir web check` проходит без ошибок и предупреждений; -- `pnpm --dir web test` — 42 test files / 449 tests; +- `pnpm --dir web test` — 42 test files / 455 tests; - `pnpm --dir web test:rules` — 4 test files / 74 tests; - `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused design tokens от token audit; -- `pnpm verify` проходит; production E2E — 110 passed, 0 skipped. +- `pnpm verify` проходит; production E2E — 112 passed, 0 skipped. Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`. diff --git a/docs/backlog.md b/docs/backlog.md index 389ee25..ff0d3ba 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -154,7 +154,9 @@ Q5 закрывает известные preview-проблемы; активн механическая часть реализована в preview — `ToolEntry.output` (`OutputFormat`: mime/ext/qualityParamId) в `web/src/lib/registry/types.ts`; `SchemaToolView.download()` кодирует по `output` (jpg/webp/bmp уже настроены; - `png-to-bmp` переведён сюда же). **Осталось (UX-райз «Download»):** + `png-to-bmp` переведён сюда же). Q6c покрывает этот фиксированный контракт + unit/E2E-проверками; переключатель форматов и quality остаётся отдельным + UX-райзом. **Осталось (UX-райз «Download»):** - выбор формата/качества прямо в кнопке/диалоге Download для любого результата (не отдельными конвертерами); - экспорт с лимитом размера → закрывает отложенный `reduce-to-size-png` diff --git a/docs/checklist-manual-testing.md b/docs/checklist-manual-testing.md index 2f59d33..edfcee4 100644 --- a/docs/checklist-manual-testing.md +++ b/docs/checklist-manual-testing.md @@ -95,10 +95,10 @@ корректный (не «кракозябры»). - [ ] remove-background на сложной полупрозрачности — края не «звенят». -## G. Закрытые Q5/Q6b-проблемы +## G. Закрытые Q5/Q6b/Q6c-проблемы -> Q5/Q6b-проблемы закрыты автоматическими тестами; для ручной проверки остаются -> субъективные сценарии разделов A–F. +> Q5/Q6b/Q6c-проблемы закрыты автоматическими тестами; для ручной проверки +> остаются субъективные сценарии разделов A–F. - [x] **Генераторы:** auto-run по дефолтам, результат и изменение параметра без кнопки Generate. `web/e2e/generators.spec.ts`. @@ -112,6 +112,8 @@ `split-into-parts-png.zip` с именованными PNG-частями; содержимое и сигнатуры проверяются `web/src/lib/zip.test.ts` и `web/e2e/pipeline.spec.ts`. +- [x] **Output MIME/quality:** JPG/WebP/BMP имеют ожидаемые расширения и + сигнатуры, quality 10/90 меняет размер файла; `web/e2e/pipeline.spec.ts`. ## Как долго diff --git a/docs/plan-codebase-quality.md b/docs/plan-codebase-quality.md index 46e7f42..52e977c 100644 --- a/docs/plan-codebase-quality.md +++ b/docs/plan-codebase-quality.md @@ -1,9 +1,9 @@ # План: линтеры, тесты и качество кодовой базы -> Статус: **draft — Q0–Q5, Q6a и Q6b готовы к ревью; Q6c — следующий этап**. +> Статус: **draft — Q0–Q5 и Q6a–Q6c готовы к ревью; Q6d — следующий этап**. > > Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и -> инфраструктурных скриптов. Q0–Q5, Q6a и Q6b выполнены; Q6c–Q8 выполняются +> инфраструктурных скриптов. Q0–Q5 и Q6a–Q6c выполнены; Q6d–Q8 выполняются > отдельными атомарными шагами. > > Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность» @@ -229,9 +229,22 @@ seam, unit round-trip для `FileResult`, защиту пустого резу - E2E использует `downloadResultFile` и `unzipSync`, не добавляя новые `data-testid`; files-инструменты не проходят через image-smoke. -#### Q6c–Q6f. Оставшиеся направления +#### Q6c. Output MIME/quality + +**Статус:** выполнен. Q6c покрывает текущий фиксированный output-контракт +JPG/WebP/BMP, не превращаясь в UX-райз кнопки Download. Registry проверяет +согласованность MIME/extension/quality-поля, unit — границы quality, browser — +реальные сигнатуры файлов и разницу размера при изменении quality. + +- `ToolEntry.output` остаётся декларативным описанием формата; новый селектор + форматов не добавляется. +- `validateOutputQuality` отделяет проверку 0..1 от DOM-рендеринга; JPEG/WebP + используют quality `1..100` из schema, BMP не имеет quality-параметра. +- E2E проверяет JPEG `FF D8 FF`, WebP `RIFF/WEBP`, BMP `BM` и PNG-фолбэк. +- Ошибки кодирования и unsupported MIME используют существующие i18n-ключи. + +#### Q6d–Q6f. Оставшиеся направления -- output MIME/quality; - malformed и special PNG fixtures: CRC, truncated, palette, 16-bit; - smoke всех типов инструментов из registry; - component-тесты `SchemaToolView`, `SchemaPreview` и schema fields; diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 0ad065a..4ec58e8 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -61,10 +61,10 @@ CSS-классы и конкретный формат временного URL `web/e2e/helpers/page.ts` владеет общими операциями `openTool`, `uploadImage`, `resultImage`, `sourceImage`, `verdictStatus`, `textResult`, `errorAlert`, -`emptyState`, `rangeInput`, `downloadResult` и `downloadResultFile`. Специфика -должна вызывать эти helpers, а не повторять raw-селекторы. Инструменты с -`result: "files"` проверяются отдельным ZIP-сценарием: общий image-smoke не -применяется к ним. +`emptyState`, `rangeInput`, `downloadResult`, `downloadResultFile` и +`downloadResultBytes`. Специфика должна вызывать эти helpers, а не повторять +raw-селекторы. Инструменты с `result: "files"` проверяются отдельным +ZIP-сценарием: общий image-smoke не применяется к ним. `test.fixme` допускается только для зарегистрированной проблемы в backlog или checklist. В комментарии указывается ссылка на неё. После исправления тест и diff --git a/web/e2e/helpers/page.ts b/web/e2e/helpers/page.ts index 3ec06c4..4726f1d 100644 --- a/web/e2e/helpers/page.ts +++ b/web/e2e/helpers/page.ts @@ -1,3 +1,4 @@ +import { readFile } from "node:fs/promises"; import { expect, type Download, @@ -88,6 +89,18 @@ export async function downloadResultFile(page: Page): Promise { return download; } +export async function downloadResultBytes( + page: Page, +): Promise<{ name: string; bytes: Uint8Array }> { + const download = await downloadResultFile(page); + const path = await download.path(); + if (!path) throw new Error("download path is unavailable"); + return { + name: download.suggestedFilename(), + bytes: new Uint8Array(await readFile(path)), + }; +} + export async function downloadResult(page: Page): Promise { return (await downloadResultFile(page)).suggestedFilename(); } diff --git a/web/e2e/pipeline.spec.ts b/web/e2e/pipeline.spec.ts index 2c3d4b9..09baa1c 100644 --- a/web/e2e/pipeline.spec.ts +++ b/web/e2e/pipeline.spec.ts @@ -1,4 +1,3 @@ -import { readFile } from "node:fs/promises"; import { unzipSync } from "fflate"; import { expect, test } from "playwright/test"; import { @@ -9,8 +8,7 @@ import { opaquePng, } from "./helpers/fixtures"; import { - downloadResult, - downloadResultFile, + downloadResultBytes, errorAlert, expectNoErrorAlert, expectNoErrors, @@ -58,17 +56,62 @@ test("flip-png: full flow - upload, result, download", async ({ page }) => { await openTool(page, "flip-png"); await uploadImage(page, landscapePng); await expect(resultImage(page)).toBeVisible(); - expect(await downloadResult(page)).toBe("flip-png.png"); + const { name, bytes } = await downloadResultBytes(page); + expect(name).toBe("flip-png.png"); + expect(Array.from(bytes.subarray(0, 8))).toEqual([ + 137, 80, 78, 71, 13, 10, 26, 10, + ]); await expectNoErrorAlert(page); expectNoErrors(sink); }); -test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => { +test("convert-png-to-jpg: produces JPEG and quality affects size", async ({ + page, +}) => { const sink = trackErrors(page); await openTool(page, "convert-png-to-jpg"); + await uploadImage(page, largePng); + await expect(resultImage(page)).toBeVisible(); + const quality = rangeInput(page, "Quality"); + await quality.fill("10"); + const low = await downloadResultBytes(page); + expect(low.name).toBe("convert-png-to-jpg.jpg"); + expect(Array.from(low.bytes.subarray(0, 3))).toEqual([255, 216, 255]); + await quality.fill("90"); + const high = await downloadResultBytes(page); + expect(high.bytes.length).toBeGreaterThan(low.bytes.length); + await expectNoErrorAlert(page); + expectNoErrors(sink); +}); + +test("convert-png-to-webp: produces WebP and quality affects size", async ({ + page, +}) => { + const sink = trackErrors(page); + await openTool(page, "convert-png-to-webp"); + await uploadImage(page, largePng); + await expect(resultImage(page)).toBeVisible(); + const quality = rangeInput(page, "Quality"); + await quality.fill("10"); + const low = await downloadResultBytes(page); + expect(low.name).toBe("convert-png-to-webp.webp"); + expect(Array.from(low.bytes.subarray(0, 4))).toEqual([82, 73, 70, 70]); + expect(Array.from(low.bytes.subarray(8, 12))).toEqual([87, 69, 66, 80]); + await quality.fill("90"); + const high = await downloadResultBytes(page); + expect(high.bytes.length).toBeGreaterThan(low.bytes.length); + await expectNoErrorAlert(page); + expectNoErrors(sink); +}); + +test("png-to-bmp: produces a BMP download", async ({ page }) => { + const sink = trackErrors(page); + await openTool(page, "png-to-bmp"); await uploadImage(page, opaquePng); await expect(resultImage(page)).toBeVisible(); - expect(await downloadResult(page)).toBe("convert-png-to-jpg.jpg"); + const { name, bytes } = await downloadResultBytes(page); + expect(name).toBe("png-to-bmp.bmp"); + expect(Array.from(bytes.subarray(0, 2))).toEqual([66, 77]); await expectNoErrorAlert(page); expectNoErrors(sink); }); @@ -102,11 +145,9 @@ test("split-into-parts-png downloads a zip with named PNG parts", async ({ await rangeInput(page, "Rows").fill("2"); await expect(page.getByText("part-1-3.png")).toBeVisible(); - const download = await downloadResultFile(page); - expect(download.suggestedFilename()).toBe("split-into-parts-png.zip"); - const path = await download.path(); - if (!path) throw new Error("download path is unavailable"); - const entries = unzipSync(new Uint8Array(await readFile(path))); + const { name, bytes } = await downloadResultBytes(page); + expect(name).toBe("split-into-parts-png.zip"); + const entries = unzipSync(bytes); expect(Object.keys(entries)).toEqual([ "part-1-1.png", diff --git a/web/src/lib/core/io.test.ts b/web/src/lib/core/io.test.ts index ce9ae40..2f78dfc 100644 --- a/web/src/lib/core/io.test.ts +++ b/web/src/lib/core/io.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { isSupportedImage, unsupportedImageError } from "./io"; +import { + isSupportedImage, + unsupportedImageError, + validateOutputQuality, +} from "./io"; describe("isSupportedImage", () => { it.each([ @@ -38,3 +42,33 @@ describe("unsupportedImageError", () => { expect(err.vars?.type).toBe("unknown"); }); }); + +describe("validateOutputQuality", () => { + it.each(["image/jpeg", "image/webp"] as const)( + "принимает границы 0 и 1 для %s", + (mime) => { + expect(() => validateOutputQuality(mime, 0)).not.toThrow(); + expect(() => validateOutputQuality(mime, 1)).not.toThrow(); + }, + ); + + it.each(["image/jpeg", "image/webp"] as const)( + "отклоняет значение вне 0..1 для %s", + (mime) => { + expect(() => validateOutputQuality(mime, -0.01)).toThrow( + "errors.qualityRange", + ); + expect(() => validateOutputQuality(mime, 1.01)).toThrow( + "errors.qualityRange", + ); + expect(() => validateOutputQuality(mime, Number.NaN)).toThrow( + "errors.qualityRange", + ); + }, + ); + + it("не применяет quality к PNG и BMP", () => { + expect(() => validateOutputQuality("image/png", 2)).not.toThrow(); + expect(() => validateOutputQuality("image/bmp", 2)).not.toThrow(); + }); +}); diff --git a/web/src/lib/core/io.ts b/web/src/lib/core/io.ts index 9389860..f8208d7 100644 --- a/web/src/lib/core/io.ts +++ b/web/src/lib/core/io.ts @@ -84,6 +84,19 @@ export async function decodeTextImage(text: string): Promise { return await decodeBytes(bytes); } +export function validateOutputQuality( + mime: OutputMime, + quality?: number, +): void { + if (mime !== "image/jpeg" && mime !== "image/webp") return; + if ( + quality !== undefined && + (!Number.isFinite(quality) || quality < 0 || quality > 1) + ) { + throw new ToolError("errors.qualityRange"); + } +} + export async function encode( img: PixelImage, mime: OutputMime = "image/png", @@ -92,11 +105,7 @@ export async function encode( if (mime === "image/bmp") { return new Blob([encodeBmpBytes(img)], { type: mime }); } - if (mime === "image/jpeg" || mime === "image/webp") { - if (quality !== undefined && (quality < 0 || quality > 1)) { - throw new ToolError("errors.qualityRange"); - } - } + validateOutputQuality(mime, quality); const canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; diff --git a/web/src/lib/registry/registry.test.ts b/web/src/lib/registry/registry.test.ts index 25015fa..9eea41b 100644 --- a/web/src/lib/registry/registry.test.ts +++ b/web/src/lib/registry/registry.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { TOOLS } from "."; import { PREVIEW_GROUPS } from "../catalog"; import type { PixelImage } from "../core/types"; +import type { OutputMime } from "../core/io"; import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema"; import type { FileResult, ToolResult } from "./types"; @@ -78,6 +79,39 @@ describe("registry-new (переведённые инструменты)", () => } }); + it("output metadata согласован с MIME и quality-схемой", () => { + const extensions: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + "image/bmp": "bmp", + }; + + for (const tool of TOOLS) { + const output = tool.output; + if (!output) continue; + expect(extensions[output.mime], `${tool.id}: mime`).toBe(output.ext); + if (output.qualityParamId) { + const qualityField = tool.schema.fields[output.qualityParamId]; + expect( + qualityField, + `${tool.id}.${output.qualityParamId}`, + ).toBeDefined(); + expect(["number", "slider"], `${tool.id}: quality kind`).toContain( + qualityField?.spec.kind, + ); + } else if (output.mime === "image/jpeg" || output.mime === "image/webp") { + expect(output.qualityParamId, `${tool.id}: quality`).toBeTruthy(); + } + if (output.mime === "image/bmp") { + expect( + output.qualityParamId, + `${tool.id}: bmp quality`, + ).toBeUndefined(); + } + } + }); + it("source-aware dimension defaults применяются только к image tools", () => { for (const tool of TOOLS) { for (const field of Object.values(tool.schema.fields)) {