diff --git a/docs/README.md b/docs/README.md index 374a43e..2957327 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,17 +51,17 @@ только если темы нет в существующем документе; иначе обновляется источник истины. -## Текущий baseline Q6a +## Текущий baseline Q6b -На момент фиксации Q6a: +На момент фиксации Q6b: - production registry содержит 122 инструмента; - `pnpm --dir web check` проходит без ошибок и предупреждений; -- `pnpm --dir web test` — 41 test files / 445 tests; +- `pnpm --dir web test` — 42 test files / 449 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 — 109 passed, 0 skipped. +- `pnpm verify` проходит; production E2E — 110 passed, 0 skipped. Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`. diff --git a/docs/backlog.md b/docs/backlog.md index 5e59337..389ee25 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -219,10 +219,12 @@ Q5 закрывает известные preview-проблемы; активн `result: "files"` в реестре, сериализация через executor, скачивание zip-архивом, грид-превью частей. Реализовано на первом потребителе `split-into-parts-png` (2026-09, `docs/archive/plan-split-into-parts.md`). - **1 → many:** подключение остальных потребителей (gif-to-frames, - separate-colors) при необходимости; формат/качество файлов внутри zip — - отложено до UX-райза «Download» (п.7). **many → 1** (вход = архив/несколько - картинок → единый результат): не реализовано. Зависит от batch-обработки - архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна отдельная - проработка входа (распаковка zip в браузере, порядок файлов, поведение в - пайплайне — такие инструменты терминальны, как и 1 → many). + Q6b закрывает контракт ZIP/download: unit-тесты `web/src/lib/zip.test.ts` и + browser-сценарий `web/e2e/pipeline.spec.ts` проверяют имена частей и + содержимое архива. **1 → many:** подключение остальных потребителей + (gif-to-frames, separate-colors) при необходимости; формат/качество файлов + внутри zip — отложено до UX-райза «Download» (п.7). **many → 1** (вход = + архив/несколько картинок → единый результат): не реализовано. Зависит от + batch-обработки архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна + отдельная проработка входа (распаковка zip в браузере, порядок файлов, + поведение в пайплайне — такие инструменты терминальны, как и 1 → many). diff --git a/docs/checklist-manual-testing.md b/docs/checklist-manual-testing.md index ca3e065..2f59d33 100644 --- a/docs/checklist-manual-testing.md +++ b/docs/checklist-manual-testing.md @@ -95,9 +95,9 @@ корректный (не «кракозябры»). - [ ] remove-background на сложной полупрозрачности — края не «звенят». -## G. Закрытые Q5-проблемы +## G. Закрытые Q5/Q6b-проблемы -> Q5-проблемы закрыты автоматическими тестами; для ручной проверки остаются +> Q5/Q6b-проблемы закрыты автоматическими тестами; для ручной проверки остаются > субъективные сценарии разделов A–F. - [x] **Генераторы:** auto-run по дефолтам, результат и изменение параметра без @@ -108,6 +108,10 @@ auto-run завершается результатом. `web/e2e/pipeline.spec.ts`. - [x] **Локализация ошибок:** alert хранит key/vars, переводится при выводе и обновляется при смене языка. `web/e2e/i18n.spec.ts`. +- [x] **FileResult → ZIP:** `split-into-parts-png` скачивает + `split-into-parts-png.zip` с именованными PNG-частями; содержимое и + сигнатуры проверяются `web/src/lib/zip.test.ts` и + `web/e2e/pipeline.spec.ts`. ## Как долго diff --git a/docs/plan-codebase-quality.md b/docs/plan-codebase-quality.md index f68439b..46e7f42 100644 --- a/docs/plan-codebase-quality.md +++ b/docs/plan-codebase-quality.md @@ -1,9 +1,9 @@ # План: линтеры, тесты и качество кодовой базы -> Статус: **draft — Q0–Q5 и Q6a готовы к ревью; Q6b — следующий этап**. +> Статус: **draft — Q0–Q5, Q6a и Q6b готовы к ревью; Q6c — следующий этап**. > > Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и -> инфраструктурных скриптов. Q0–Q5 и Q6a выполнены; Q6b–Q8 выполняются +> инфраструктурных скриптов. Q0–Q5, Q6a и Q6b выполнены; Q6c–Q8 выполняются > отдельными атомарными шагами. > > Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность» @@ -216,9 +216,21 @@ malformed fallback, source-aware sanitization и DOM metadata. Browser smoke - `debounce` покрыт fake timers; E2E подтверждает последние значения при быстрых последовательных изменениях генератора. -#### Q6b–Q6f. Оставшиеся направления +#### Q6b. FileResult → ZIP → download + +**Статус:** выполнен. Q6a закрыл worker-транспорт; Q6b добавил проверяемый ZIP +seam, unit round-trip для `FileResult`, защиту пустого результата и error/busy +контракт download. Отдельный browser-сценарий скачивает архив и проверяет имена +частей и PNG-сигнатуры. + +- `buildZipEntries` не зависит от DOM; `downloadZip` остаётся UI-обёрткой. +- `SchemaToolView` переиспользует `downloadBlob`, ловит ошибки кодирования и + блокирует повторный download на время сборки. +- E2E использует `downloadResultFile` и `unzipSync`, не добавляя новые + `data-testid`; files-инструменты не проходят через image-smoke. + +#### Q6c–Q6f. Оставшиеся направления -- `FileResult` → ZIP → download; - output MIME/quality; - malformed и special PNG fixtures: CRC, truncated, palette, 16-bit; - smoke всех типов инструментов из registry; diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index b66fe94..0ad065a 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -61,8 +61,10 @@ CSS-классы и конкретный формат временного URL `web/e2e/helpers/page.ts` владеет общими операциями `openTool`, `uploadImage`, `resultImage`, `sourceImage`, `verdictStatus`, `textResult`, `errorAlert`, -`emptyState`, `rangeInput` и `downloadResult`. Специфика должна вызывать эти -helpers, а не повторять raw-селекторы. +`emptyState`, `rangeInput`, `downloadResult` и `downloadResultFile`. Специфика +должна вызывать эти 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 b6c81f9..3ec06c4 100644 --- a/web/e2e/helpers/page.ts +++ b/web/e2e/helpers/page.ts @@ -1,4 +1,9 @@ -import { expect, type Locator, type Page } from "playwright/test"; +import { + expect, + type Download, + type Locator, + type Page, +} from "playwright/test"; import type { SourceFile } from "./fixtures"; export const TEST_IDS = { @@ -73,14 +78,18 @@ export async function uploadImage(page: Page, file: SourceFile): Promise { }); } -export async function downloadResult(page: Page): Promise { +export async function downloadResultFile(page: Page): Promise { const button = page.getByRole("button", { name: "Download result" }); await expect(button).toBeVisible(); const [download] = await Promise.all([ page.waitForEvent("download"), button.click(), ]); - return download.suggestedFilename(); + return download; +} + +export async function downloadResult(page: Page): Promise { + return (await downloadResultFile(page)).suggestedFilename(); } export async function expectNoErrorAlert(page: Page): Promise { diff --git a/web/e2e/pipeline.spec.ts b/web/e2e/pipeline.spec.ts index e3dd332..2c3d4b9 100644 --- a/web/e2e/pipeline.spec.ts +++ b/web/e2e/pipeline.spec.ts @@ -1,3 +1,5 @@ +import { readFile } from "node:fs/promises"; +import { unzipSync } from "fflate"; import { expect, test } from "playwright/test"; import { corruptPng, @@ -8,6 +10,7 @@ import { } from "./helpers/fixtures"; import { downloadResult, + downloadResultFile, errorAlert, expectNoErrorAlert, expectNoErrors, @@ -89,6 +92,39 @@ test("reset restores defaults and re-runs with the source", async ({ expectNoErrors(sink); }); +test("split-into-parts-png downloads a zip with named PNG parts", async ({ + page, +}) => { + const sink = trackErrors(page); + await openTool(page, "split-into-parts-png"); + await uploadImage(page, landscapePng); + await rangeInput(page, "Columns").fill("3"); + 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))); + + expect(Object.keys(entries)).toEqual([ + "part-1-1.png", + "part-1-2.png", + "part-1-3.png", + "part-2-1.png", + "part-2-2.png", + "part-2-3.png", + ]); + for (const bytes of Object.values(entries)) { + expect(Array.from(bytes.subarray(0, 8))).toEqual([ + 137, 80, 78, 71, 13, 10, 26, 10, + ]); + } + await expectNoErrorAlert(page); + expectNoErrors(sink); +}); + test("blur: changing slider updates result image", async ({ page }) => { await openTool(page, "blur-png"); await uploadImage(page, opaquePng); diff --git a/web/src/lib/components/SchemaPreview.svelte b/web/src/lib/components/SchemaPreview.svelte index 76fe51e..0addba8 100644 --- a/web/src/lib/components/SchemaPreview.svelte +++ b/web/src/lib/components/SchemaPreview.svelte @@ -62,7 +62,7 @@ ? `${result.width} × ${result.height} px` : "—" : resultKind === "files" - ? fileResult + ? fileResult && fileResult.files.length > 0 ? `${fileResult.files.length} ${t("resultCard.parts")}` : "—" : textResult @@ -74,7 +74,7 @@ resultKind === "image" ? Boolean(result) : resultKind === "files" - ? Boolean(fileResult) + ? Boolean(fileResult && fileResult.files.length > 0) : Boolean(textResult), ); diff --git a/web/src/lib/components/SchemaToolView.svelte b/web/src/lib/components/SchemaToolView.svelte index 61fadc4..b58950c 100644 --- a/web/src/lib/components/SchemaToolView.svelte +++ b/web/src/lib/components/SchemaToolView.svelte @@ -3,7 +3,7 @@ import SchemaPreview from "$lib/components/SchemaPreview.svelte"; import { debounce } from "$lib/core/debounce"; import { ToolError, type ErrorVars } from "$lib/core/errors"; - import { decodeFile, encode } from "$lib/core/io"; + import { decodeFile, downloadBlob, encode } from "$lib/core/io"; import type { PixelImage } from "$lib/core/types"; import { execute } from "$lib/executor"; import { @@ -136,23 +136,26 @@ async function download() { if (!schema) return; - if (resultKind === "files") { - if (!fileResult) return; - await downloadZip(fileResult.files, `${tool.id}.zip`); - return; + running = true; + displayError = null; + try { + if (resultKind === "files") { + if (!fileResult) return; + await downloadZip(fileResult.files, `${tool.id}.zip`); + return; + } + if (!result) return; + const out = tool.output; + const quality = out?.qualityParamId + ? Number(values[out.qualityParamId]) / 100 + : undefined; + const blob = await encode(result, out?.mime ?? "image/png", quality); + downloadBlob(blob, `${tool.id}.${out?.ext ?? "png"}`); + } catch (e) { + displayError = toDisplayError(e); + } finally { + running = false; } - if (!result) return; - const out = tool.output; - const quality = out?.qualityParamId - ? Number(values[out.qualityParamId]) / 100 - : undefined; - const blob = await encode(result, out?.mime ?? "image/png", quality); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${tool.id}.${out?.ext ?? "png"}`; - a.click(); - URL.revokeObjectURL(url); } async function copyText() { @@ -170,13 +173,12 @@ async function downloadText() { if (!textResult) return; - const blob = new Blob([textResult], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${tool.id}.txt`; - a.click(); - URL.revokeObjectURL(url); + try { + const blob = new Blob([textResult], { type: "text/plain" }); + downloadBlob(blob, `${tool.id}.txt`); + } catch (e) { + displayError = toDisplayError(e); + } } $effect(() => { diff --git a/web/src/lib/executor/executor.test.ts b/web/src/lib/executor/executor.test.ts index 34b549a..91bc986 100644 --- a/web/src/lib/executor/executor.test.ts +++ b/web/src/lib/executor/executor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { ToolError } from "../core/errors"; +import type { PixelImage } from "../core/types"; import { createExecutor, type ExecuteContext, @@ -8,6 +9,7 @@ import { } from "./executor"; import { decodeWorkerResponse, + encodeWorkerResponse, type WorkerRequest, type WorkerResponse, } from "./protocol"; @@ -244,6 +246,38 @@ describe("createExecutor", () => { files: [{ name: "part.png", image: { width: 1, height: 1 } }], }); }); + + it("round-trips FileResult through the worker protocol", () => { + const fileImage: PixelImage = { + width: 1, + height: 1, + data: new Uint8ClampedArray([1, 2, 3, 4]), + }; + const fileResult = { + files: [{ name: "part-1-1.png", image: fileImage }], + }; + const encoded = encodeWorkerResponse(4, fileResult); + + expect(encoded.transfer).toHaveLength(1); + expect(decodeWorkerResponse(encoded.response)).toEqual(fileResult); + }); + + it("falls back when a files payload is malformed", async () => { + const { factory, workers } = createFactory(); + const execute = createExecutor({ workerFactory: factory }); + const tool = makeTool(() => "direct"); + const promise = execute(tool, { params: {} }); + const worker = workers[0]; + const request = worker.requests[0]; + worker.respond({ + id: request.id, + ok: true, + files: [{ name: "part.png", width: 1, height: 1 }], + } as unknown as WorkerResponse); + + await expect(promise).resolves.toBe("direct"); + expect(worker.terminated).toBe(true); + }); }); describe("handleWorkerRequest", () => { diff --git a/web/src/lib/zip.test.ts b/web/src/lib/zip.test.ts new file mode 100644 index 0000000..3b267dd --- /dev/null +++ b/web/src/lib/zip.test.ts @@ -0,0 +1,44 @@ +import { unzipSync, zipSync } from "fflate"; +import { describe, expect, it, vi } from "vitest"; +import type { PixelImage } from "./core/types"; +import { buildZipEntries, type ZipImageEncoder } from "./zip"; +import type { ToolImageFile } from "./registry"; + +function image(value: number): PixelImage { + return { + width: 1, + height: 1, + data: new Uint8ClampedArray([value, value + 1, value + 2, 255]), + }; +} + +describe("buildZipEntries", () => { + it("preserves file order and encoded bytes", async () => { + const files: ToolImageFile[] = [ + { name: "part-1-1.png", image: image(10) }, + { name: "part-1-2.png", image: image(20) }, + ]; + const encodeImage = vi.fn( + async (source: PixelImage) => + new Uint8Array([source.data[0], source.data[3]]), + ); + + const entries = await buildZipEntries(files, encodeImage); + const unzipped = unzipSync(zipSync(entries)); + + expect(Object.keys(entries)).toEqual(["part-1-1.png", "part-1-2.png"]); + expect(Object.keys(unzipped)).toEqual(["part-1-1.png", "part-1-2.png"]); + expect(unzipped["part-1-1.png"]).toEqual(new Uint8Array([10, 255])); + expect(unzipped["part-1-2.png"]).toEqual(new Uint8Array([20, 255])); + expect(encodeImage).toHaveBeenCalledTimes(2); + expect(encodeImage.mock.calls[0]?.[0]).toBe(files[0]?.image); + expect(encodeImage.mock.calls[1]?.[0]).toBe(files[1]?.image); + }); + + it("returns an empty entry set without calling the encoder", async () => { + const encodeImage = vi.fn(); + + await expect(buildZipEntries([], encodeImage)).resolves.toEqual({}); + expect(encodeImage).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/zip.ts b/web/src/lib/zip.ts index 4b5b8f6..130401f 100644 --- a/web/src/lib/zip.ts +++ b/web/src/lib/zip.ts @@ -1,7 +1,21 @@ import { zipSync } from "fflate"; import { downloadBlob, encode } from "./core/io"; +import type { PixelImage } from "./core/types"; import type { ToolImageFile } from "./registry"; +export type ZipImageEncoder = (image: PixelImage) => Promise; + +export async function buildZipEntries( + files: ToolImageFile[], + encodeImage: ZipImageEncoder, +): Promise> { + const entries: Record = {}; + for (const file of files) { + entries[file.name] = await encodeImage(file.image); + } + return entries; +} + /** * Собирает набор картинок (1 → many) в zip-архив и скачивает его. * Картинки кодируются как PNG; кодирование требует DOM — вызов из UI-слоя. @@ -11,11 +25,10 @@ export async function downloadZip( zipName: string, ): Promise { if (files.length === 0) return; - const entries: Record = {}; - for (const file of files) { - const blob = await encode(file.image, "image/png"); - entries[file.name] = new Uint8Array(await blob.arrayBuffer()); - } + const entries = await buildZipEntries(files, async (image) => { + const blob = await encode(image, "image/png"); + return new Uint8Array(await blob.arrayBuffer()); + }); const zipped = zipSync(entries); downloadBlob(new Blob([zipped], { type: "application/zip" }), zipName); }