mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
test: add download zip flow
This commit is contained in:
+4
-4
@@ -51,17 +51,17 @@
|
|||||||
только если темы нет в существующем документе; иначе обновляется источник
|
только если темы нет в существующем документе; иначе обновляется источник
|
||||||
истины.
|
истины.
|
||||||
|
|
||||||
## Текущий baseline Q6a
|
## Текущий baseline Q6b
|
||||||
|
|
||||||
На момент фиксации Q6a:
|
На момент фиксации Q6b:
|
||||||
|
|
||||||
- production registry содержит 122 инструмента;
|
- production registry содержит 122 инструмента;
|
||||||
- `pnpm --dir web check` проходит без ошибок и предупреждений;
|
- `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 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 — 109 passed, 0 skipped.
|
- `pnpm verify` проходит; production E2E — 110 passed, 0 skipped.
|
||||||
|
|
||||||
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`.
|
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`.
|
||||||
|
|
||||||
|
|||||||
+9
-7
@@ -219,10 +219,12 @@ Q5 закрывает известные preview-проблемы; активн
|
|||||||
`result: "files"` в реестре, сериализация через executor, скачивание
|
`result: "files"` в реестре, сериализация через executor, скачивание
|
||||||
zip-архивом, грид-превью частей. Реализовано на первом потребителе
|
zip-архивом, грид-превью частей. Реализовано на первом потребителе
|
||||||
`split-into-parts-png` (2026-09, `docs/archive/plan-split-into-parts.md`).
|
`split-into-parts-png` (2026-09, `docs/archive/plan-split-into-parts.md`).
|
||||||
**1 → many:** подключение остальных потребителей (gif-to-frames,
|
Q6b закрывает контракт ZIP/download: unit-тесты `web/src/lib/zip.test.ts` и
|
||||||
separate-colors) при необходимости; формат/качество файлов внутри zip —
|
browser-сценарий `web/e2e/pipeline.spec.ts` проверяют имена частей и
|
||||||
отложено до UX-райза «Download» (п.7). **many → 1** (вход = архив/несколько
|
содержимое архива. **1 → many:** подключение остальных потребителей
|
||||||
картинок → единый результат): не реализовано. Зависит от batch-обработки
|
(gif-to-frames, separate-colors) при необходимости; формат/качество файлов
|
||||||
архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна отдельная
|
внутри zip — отложено до UX-райза «Download» (п.7). **many → 1** (вход =
|
||||||
проработка входа (распаковка zip в браузере, порядок файлов, поведение в
|
архив/несколько картинок → единый результат): не реализовано. Зависит от
|
||||||
пайплайне — такие инструменты терминальны, как и 1 → many).
|
batch-обработки архивов (п.5 идей, roadmap фаза 8 «batch-страница»). Нужна
|
||||||
|
отдельная проработка входа (распаковка zip в браузере, порядок файлов,
|
||||||
|
поведение в пайплайне — такие инструменты терминальны, как и 1 → many).
|
||||||
|
|||||||
@@ -95,9 +95,9 @@
|
|||||||
корректный (не «кракозябры»).
|
корректный (не «кракозябры»).
|
||||||
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
|
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
|
||||||
|
|
||||||
## G. Закрытые Q5-проблемы
|
## G. Закрытые Q5/Q6b-проблемы
|
||||||
|
|
||||||
> Q5-проблемы закрыты автоматическими тестами; для ручной проверки остаются
|
> Q5/Q6b-проблемы закрыты автоматическими тестами; для ручной проверки остаются
|
||||||
> субъективные сценарии разделов A–F.
|
> субъективные сценарии разделов A–F.
|
||||||
|
|
||||||
- [x] **Генераторы:** auto-run по дефолтам, результат и изменение параметра без
|
- [x] **Генераторы:** auto-run по дефолтам, результат и изменение параметра без
|
||||||
@@ -108,6 +108,10 @@
|
|||||||
auto-run завершается результатом. `web/e2e/pipeline.spec.ts`.
|
auto-run завершается результатом. `web/e2e/pipeline.spec.ts`.
|
||||||
- [x] **Локализация ошибок:** alert хранит key/vars, переводится при выводе и
|
- [x] **Локализация ошибок:** alert хранит key/vars, переводится при выводе и
|
||||||
обновляется при смене языка. `web/e2e/i18n.spec.ts`.
|
обновляется при смене языка. `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`.
|
||||||
|
|
||||||
## Как долго
|
## Как долго
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# План: линтеры, тесты и качество кодовой базы
|
# План: линтеры, тесты и качество кодовой базы
|
||||||
|
|
||||||
> Статус: **draft — Q0–Q5 и Q6a готовы к ревью; Q6b — следующий этап**.
|
> Статус: **draft — Q0–Q5, Q6a и Q6b готовы к ревью; Q6c — следующий этап**.
|
||||||
>
|
>
|
||||||
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
|
> Источник: `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 подтверждает последние значения при быстрых
|
- `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;
|
- 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;
|
||||||
|
|||||||
@@ -61,8 +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`. Специфика должна вызывать эти
|
`emptyState`, `rangeInput`, `downloadResult` и `downloadResultFile`. Специфика
|
||||||
helpers, а не повторять raw-селекторы.
|
должна вызывать эти helpers, а не повторять raw-селекторы. Инструменты с
|
||||||
|
`result: "files"` проверяются отдельным ZIP-сценарием: общий image-smoke не
|
||||||
|
применяется к ним.
|
||||||
|
|
||||||
`test.fixme` допускается только для зарегистрированной проблемы в backlog или
|
`test.fixme` допускается только для зарегистрированной проблемы в backlog или
|
||||||
checklist. В комментарии указывается ссылка на неё. После исправления тест и
|
checklist. В комментарии указывается ссылка на неё. После исправления тест и
|
||||||
|
|||||||
+12
-3
@@ -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";
|
import type { SourceFile } from "./fixtures";
|
||||||
|
|
||||||
export const TEST_IDS = {
|
export const TEST_IDS = {
|
||||||
@@ -73,14 +78,18 @@ export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadResult(page: Page): Promise<string> {
|
export async function downloadResultFile(page: Page): Promise<Download> {
|
||||||
const button = page.getByRole("button", { name: "Download result" });
|
const button = page.getByRole("button", { name: "Download result" });
|
||||||
await expect(button).toBeVisible();
|
await expect(button).toBeVisible();
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent("download"),
|
page.waitForEvent("download"),
|
||||||
button.click(),
|
button.click(),
|
||||||
]);
|
]);
|
||||||
return download.suggestedFilename();
|
return download;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadResult(page: Page): Promise<string> {
|
||||||
|
return (await downloadResultFile(page)).suggestedFilename();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectNoErrorAlert(page: Page): Promise<void> {
|
export async function expectNoErrorAlert(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { unzipSync } from "fflate";
|
||||||
import { expect, test } from "playwright/test";
|
import { expect, test } from "playwright/test";
|
||||||
import {
|
import {
|
||||||
corruptPng,
|
corruptPng,
|
||||||
@@ -8,6 +10,7 @@ import {
|
|||||||
} from "./helpers/fixtures";
|
} from "./helpers/fixtures";
|
||||||
import {
|
import {
|
||||||
downloadResult,
|
downloadResult,
|
||||||
|
downloadResultFile,
|
||||||
errorAlert,
|
errorAlert,
|
||||||
expectNoErrorAlert,
|
expectNoErrorAlert,
|
||||||
expectNoErrors,
|
expectNoErrors,
|
||||||
@@ -89,6 +92,39 @@ test("reset restores defaults and re-runs with the source", async ({
|
|||||||
expectNoErrors(sink);
|
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 }) => {
|
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);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
? `${result.width} × ${result.height} px`
|
? `${result.width} × ${result.height} px`
|
||||||
: "—"
|
: "—"
|
||||||
: resultKind === "files"
|
: resultKind === "files"
|
||||||
? fileResult
|
? fileResult && fileResult.files.length > 0
|
||||||
? `${fileResult.files.length} ${t("resultCard.parts")}`
|
? `${fileResult.files.length} ${t("resultCard.parts")}`
|
||||||
: "—"
|
: "—"
|
||||||
: textResult
|
: textResult
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
resultKind === "image"
|
resultKind === "image"
|
||||||
? Boolean(result)
|
? Boolean(result)
|
||||||
: resultKind === "files"
|
: resultKind === "files"
|
||||||
? Boolean(fileResult)
|
? Boolean(fileResult && fileResult.files.length > 0)
|
||||||
: Boolean(textResult),
|
: Boolean(textResult),
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import SchemaPreview from "$lib/components/SchemaPreview.svelte";
|
import SchemaPreview from "$lib/components/SchemaPreview.svelte";
|
||||||
import { debounce } from "$lib/core/debounce";
|
import { debounce } from "$lib/core/debounce";
|
||||||
import { ToolError, type ErrorVars } from "$lib/core/errors";
|
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 type { PixelImage } from "$lib/core/types";
|
||||||
import { execute } from "$lib/executor";
|
import { execute } from "$lib/executor";
|
||||||
import {
|
import {
|
||||||
@@ -136,23 +136,26 @@
|
|||||||
|
|
||||||
async function download() {
|
async function download() {
|
||||||
if (!schema) return;
|
if (!schema) return;
|
||||||
if (resultKind === "files") {
|
running = true;
|
||||||
if (!fileResult) return;
|
displayError = null;
|
||||||
await downloadZip(fileResult.files, `${tool.id}.zip`);
|
try {
|
||||||
return;
|
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() {
|
async function copyText() {
|
||||||
@@ -170,13 +173,12 @@
|
|||||||
|
|
||||||
async function downloadText() {
|
async function downloadText() {
|
||||||
if (!textResult) return;
|
if (!textResult) return;
|
||||||
const blob = new Blob([textResult], { type: "text/plain" });
|
try {
|
||||||
const url = URL.createObjectURL(blob);
|
const blob = new Blob([textResult], { type: "text/plain" });
|
||||||
const a = document.createElement("a");
|
downloadBlob(blob, `${tool.id}.txt`);
|
||||||
a.href = url;
|
} catch (e) {
|
||||||
a.download = `${tool.id}.txt`;
|
displayError = toDisplayError(e);
|
||||||
a.click();
|
}
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { ToolError } from "../core/errors";
|
import { ToolError } from "../core/errors";
|
||||||
|
import type { PixelImage } from "../core/types";
|
||||||
import {
|
import {
|
||||||
createExecutor,
|
createExecutor,
|
||||||
type ExecuteContext,
|
type ExecuteContext,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
} from "./executor";
|
} from "./executor";
|
||||||
import {
|
import {
|
||||||
decodeWorkerResponse,
|
decodeWorkerResponse,
|
||||||
|
encodeWorkerResponse,
|
||||||
type WorkerRequest,
|
type WorkerRequest,
|
||||||
type WorkerResponse,
|
type WorkerResponse,
|
||||||
} from "./protocol";
|
} from "./protocol";
|
||||||
@@ -244,6 +246,38 @@ describe("createExecutor", () => {
|
|||||||
files: [{ name: "part.png", image: { width: 1, height: 1 } }],
|
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", () => {
|
describe("handleWorkerRequest", () => {
|
||||||
|
|||||||
@@ -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<ZipImageEncoder>();
|
||||||
|
|
||||||
|
await expect(buildZipEntries([], encodeImage)).resolves.toEqual({});
|
||||||
|
expect(encodeImage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+18
-5
@@ -1,7 +1,21 @@
|
|||||||
import { zipSync } from "fflate";
|
import { zipSync } from "fflate";
|
||||||
import { downloadBlob, encode } from "./core/io";
|
import { downloadBlob, encode } from "./core/io";
|
||||||
|
import type { PixelImage } from "./core/types";
|
||||||
import type { ToolImageFile } from "./registry";
|
import type { ToolImageFile } from "./registry";
|
||||||
|
|
||||||
|
export type ZipImageEncoder = (image: PixelImage) => Promise<Uint8Array>;
|
||||||
|
|
||||||
|
export async function buildZipEntries(
|
||||||
|
files: ToolImageFile[],
|
||||||
|
encodeImage: ZipImageEncoder,
|
||||||
|
): Promise<Record<string, Uint8Array>> {
|
||||||
|
const entries: Record<string, Uint8Array> = {};
|
||||||
|
for (const file of files) {
|
||||||
|
entries[file.name] = await encodeImage(file.image);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Собирает набор картинок (1 → many) в zip-архив и скачивает его.
|
* Собирает набор картинок (1 → many) в zip-архив и скачивает его.
|
||||||
* Картинки кодируются как PNG; кодирование требует DOM — вызов из UI-слоя.
|
* Картинки кодируются как PNG; кодирование требует DOM — вызов из UI-слоя.
|
||||||
@@ -11,11 +25,10 @@ export async function downloadZip(
|
|||||||
zipName: string,
|
zipName: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
const entries: Record<string, Uint8Array> = {};
|
const entries = await buildZipEntries(files, async (image) => {
|
||||||
for (const file of files) {
|
const blob = await encode(image, "image/png");
|
||||||
const blob = await encode(file.image, "image/png");
|
return new Uint8Array(await blob.arrayBuffer());
|
||||||
entries[file.name] = new Uint8Array(await blob.arrayBuffer());
|
});
|
||||||
}
|
|
||||||
const zipped = zipSync(entries);
|
const zipped = zipSync(entries);
|
||||||
downloadBlob(new Blob([zipped], { type: "application/zip" }), zipName);
|
downloadBlob(new Blob([zipped], { type: "application/zip" }), zipName);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user