test: add download mime, quality verification

This commit is contained in:
2026-09-26 03:33:09 +05:00
parent 746d6a6c8f
commit 712fd3cb2e
10 changed files with 181 additions and 33 deletions
+4 -4
View File
@@ -51,17 +51,17 @@
только если темы нет в существующем документе; иначе обновляется источник только если темы нет в существующем документе; иначе обновляется источник
истины. истины.
## Текущий baseline Q6b ## Текущий baseline Q6c
На момент фиксации Q6b: На момент фиксации Q6c:
- production registry содержит 122 инструмента; - production registry содержит 122 инструмента;
- `pnpm --dir web check` проходит без ошибок и предупреждений; - `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 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 — 110 passed, 0 skipped. - `pnpm verify` проходит; production E2E — 112 passed, 0 skipped.
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`. Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`.
+3 -1
View File
@@ -154,7 +154,9 @@ Q5 закрывает известные preview-проблемы; активн
механическая часть реализована в preview — `ToolEntry.output` механическая часть реализована в preview — `ToolEntry.output`
(`OutputFormat`: mime/ext/qualityParamId) в `web/src/lib/registry/types.ts`; (`OutputFormat`: mime/ext/qualityParamId) в `web/src/lib/registry/types.ts`;
`SchemaToolView.download()` кодирует по `output` (jpg/webp/bmp уже настроены; `SchemaToolView.download()` кодирует по `output` (jpg/webp/bmp уже настроены;
`png-to-bmp` переведён сюда же). **Осталось (UX-райз «Download»):** `png-to-bmp` переведён сюда же). Q6c покрывает этот фиксированный контракт
unit/E2E-проверками; переключатель форматов и quality остаётся отдельным
UX-райзом. **Осталось (UX-райз «Download»):**
- выбор формата/качества прямо в кнопке/диалоге Download для любого - выбор формата/качества прямо в кнопке/диалоге Download для любого
результата (не отдельными конвертерами); результата (не отдельными конвертерами);
- экспорт с лимитом размера → закрывает отложенный `reduce-to-size-png` - экспорт с лимитом размера → закрывает отложенный `reduce-to-size-png`
+5 -3
View File
@@ -95,10 +95,10 @@
корректный (не «кракозябры»). корректный (не «кракозябры»).
- [ ] remove-background на сложной полупрозрачности — края не «звенят». - [ ] remove-background на сложной полупрозрачности — края не «звенят».
## G. Закрытые Q5/Q6b-проблемы ## G. Закрытые Q5/Q6b/Q6c-проблемы
> Q5/Q6b-проблемы закрыты автоматическими тестами; для ручной проверки остаются > Q5/Q6b/Q6c-проблемы закрыты автоматическими тестами; для ручной проверки
> субъективные сценарии разделов A–F. > остаются субъективные сценарии разделов A–F.
- [x] **Генераторы:** auto-run по дефолтам, результат и изменение параметра без - [x] **Генераторы:** auto-run по дефолтам, результат и изменение параметра без
кнопки Generate. `web/e2e/generators.spec.ts`. кнопки Generate. `web/e2e/generators.spec.ts`.
@@ -112,6 +112,8 @@
`split-into-parts-png.zip` с именованными PNG-частями; содержимое и `split-into-parts-png.zip` с именованными PNG-частями; содержимое и
сигнатуры проверяются `web/src/lib/zip.test.ts` и сигнатуры проверяются `web/src/lib/zip.test.ts` и
`web/e2e/pipeline.spec.ts`. `web/e2e/pipeline.spec.ts`.
- [x] **Output MIME/quality:** JPG/WebP/BMP имеют ожидаемые расширения и
сигнатуры, quality 10/90 меняет размер файла; `web/e2e/pipeline.spec.ts`.
## Как долго ## Как долго
+17 -4
View File
@@ -1,9 +1,9 @@
# План: линтеры, тесты и качество кодовой базы # План: линтеры, тесты и качество кодовой базы
> Статус: **draft — Q0–Q5, Q6a и Q6b готовы к ревью; Q6c — следующий этап**. > Статус: **draft — Q0–Q5 и Q6a–Q6c готовы к ревью; Q6d — следующий этап**.
> >
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и > Источник: `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`, не добавляя новые - E2E использует `downloadResultFile` и `unzipSync`, не добавляя новые
`data-testid`; files-инструменты не проходят через image-smoke. `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; - malformed и special PNG fixtures: CRC, truncated, palette, 16-bit;
- smoke всех типов инструментов из registry; - smoke всех типов инструментов из registry;
- component-тесты `SchemaToolView`, `SchemaPreview` и schema fields; - component-тесты `SchemaToolView`, `SchemaPreview` и schema fields;
+4 -4
View File
@@ -61,10 +61,10 @@ CSS-классы и конкретный формат временного URL
`web/e2e/helpers/page.ts` владеет общими операциями `openTool`, `uploadImage`, `web/e2e/helpers/page.ts` владеет общими операциями `openTool`, `uploadImage`,
`resultImage`, `sourceImage`, `verdictStatus`, `textResult`, `errorAlert`, `resultImage`, `sourceImage`, `verdictStatus`, `textResult`, `errorAlert`,
`emptyState`, `rangeInput`, `downloadResult` и `downloadResultFile`. Специфика `emptyState`, `rangeInput`, `downloadResult`, `downloadResultFile` и
должна вызывать эти helpers, а не повторять raw-селекторы. Инструменты с `downloadResultBytes`. Специфика должна вызывать эти helpers, а не повторять
`result: "files"` проверяются отдельным ZIP-сценарием: общий image-smoke не raw-селекторы. Инструменты с `result: "files"` проверяются отдельным
применяется к ним. ZIP-сценарием: общий image-smoke не применяется к ним.
`test.fixme` допускается только для зарегистрированной проблемы в backlog или `test.fixme` допускается только для зарегистрированной проблемы в backlog или
checklist. В комментарии указывается ссылка на неё. После исправления тест и checklist. В комментарии указывается ссылка на неё. После исправления тест и
+13
View File
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises";
import { import {
expect, expect,
type Download, type Download,
@@ -88,6 +89,18 @@ export async function downloadResultFile(page: Page): Promise<Download> {
return download; 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<string> { export async function downloadResult(page: Page): Promise<string> {
return (await downloadResultFile(page)).suggestedFilename(); return (await downloadResultFile(page)).suggestedFilename();
} }
+52 -11
View File
@@ -1,4 +1,3 @@
import { readFile } from "node:fs/promises";
import { unzipSync } from "fflate"; import { unzipSync } from "fflate";
import { expect, test } from "playwright/test"; import { expect, test } from "playwright/test";
import { import {
@@ -9,8 +8,7 @@ import {
opaquePng, opaquePng,
} from "./helpers/fixtures"; } from "./helpers/fixtures";
import { import {
downloadResult, downloadResultBytes,
downloadResultFile,
errorAlert, errorAlert,
expectNoErrorAlert, expectNoErrorAlert,
expectNoErrors, expectNoErrors,
@@ -58,17 +56,62 @@ test("flip-png: full flow - upload, result, download", async ({ page }) => {
await openTool(page, "flip-png"); await openTool(page, "flip-png");
await uploadImage(page, landscapePng); await uploadImage(page, landscapePng);
await expect(resultImage(page)).toBeVisible(); 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); await expectNoErrorAlert(page);
expectNoErrors(sink); 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); const sink = trackErrors(page);
await openTool(page, "convert-png-to-jpg"); 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 uploadImage(page, opaquePng);
await expect(resultImage(page)).toBeVisible(); 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); await expectNoErrorAlert(page);
expectNoErrors(sink); 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 rangeInput(page, "Rows").fill("2");
await expect(page.getByText("part-1-3.png")).toBeVisible(); await expect(page.getByText("part-1-3.png")).toBeVisible();
const download = await downloadResultFile(page); const { name, bytes } = await downloadResultBytes(page);
expect(download.suggestedFilename()).toBe("split-into-parts-png.zip"); expect(name).toBe("split-into-parts-png.zip");
const path = await download.path(); const entries = unzipSync(bytes);
if (!path) throw new Error("download path is unavailable");
const entries = unzipSync(new Uint8Array(await readFile(path)));
expect(Object.keys(entries)).toEqual([ expect(Object.keys(entries)).toEqual([
"part-1-1.png", "part-1-1.png",
+35 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { isSupportedImage, unsupportedImageError } from "./io"; import {
isSupportedImage,
unsupportedImageError,
validateOutputQuality,
} from "./io";
describe("isSupportedImage", () => { describe("isSupportedImage", () => {
it.each([ it.each([
@@ -38,3 +42,33 @@ describe("unsupportedImageError", () => {
expect(err.vars?.type).toBe("unknown"); 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();
});
});
+14 -5
View File
@@ -84,6 +84,19 @@ export async function decodeTextImage(text: string): Promise<PixelImage> {
return await decodeBytes(bytes); 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( export async function encode(
img: PixelImage, img: PixelImage,
mime: OutputMime = "image/png", mime: OutputMime = "image/png",
@@ -92,11 +105,7 @@ export async function encode(
if (mime === "image/bmp") { if (mime === "image/bmp") {
return new Blob([encodeBmpBytes(img)], { type: mime }); return new Blob([encodeBmpBytes(img)], { type: mime });
} }
if (mime === "image/jpeg" || mime === "image/webp") { validateOutputQuality(mime, quality);
if (quality !== undefined && (quality < 0 || quality > 1)) {
throw new ToolError("errors.qualityRange");
}
}
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = img.width; canvas.width = img.width;
canvas.height = img.height; canvas.height = img.height;
+34
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { TOOLS } from "."; import { TOOLS } from ".";
import { PREVIEW_GROUPS } from "../catalog"; import { PREVIEW_GROUPS } from "../catalog";
import type { PixelImage } from "../core/types"; import type { PixelImage } from "../core/types";
import type { OutputMime } from "../core/io";
import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema"; import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
import type { FileResult, ToolResult } from "./types"; import type { FileResult, ToolResult } from "./types";
@@ -78,6 +79,39 @@ describe("registry-new (переведённые инструменты)", () =>
} }
}); });
it("output metadata согласован с MIME и quality-схемой", () => {
const extensions: Record<OutputMime, string> = {
"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", () => { it("source-aware dimension defaults применяются только к image tools", () => {
for (const tool of TOOLS) { for (const tool of TOOLS) {
for (const field of Object.values(tool.schema.fields)) { for (const field of Object.values(tool.schema.fields)) {