feat: complete TASK-WP4-05 client export
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
const session = {
|
||||
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
|
||||
csrf_token: "csrf-export-editor-0000000000000000000000000000000000",
|
||||
expires_at: "2026-09-03T08:00:00.000Z",
|
||||
user: { creator_name: "Export User", role: "user", social_id: "@export_user", status: "active", user_id: "00000000-0000-4000-8000-000000000910" },
|
||||
};
|
||||
|
||||
function canvasForRatio(ratio: CanvasState["ratio"], assetId: string): CanvasState {
|
||||
const pixels = ratio === "3:4" ? [1080, 1440] : ratio === "1:1" ? [1080, 1080] : ratio === "4:3" ? [1440, 1080] : [1080, 1920];
|
||||
return {
|
||||
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: assetId },
|
||||
elements: [], pixel_height: pixels[1]!, pixel_width: pixels[0]!, ratio, schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface Backend {
|
||||
canvas: CanvasState;
|
||||
latestBodies: Buffer[];
|
||||
saves: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
function evidence(caseId: string, file: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_EXPORT;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, caseId);
|
||||
const path = resolve(directory, file);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function routeEditor(page: Page, projectId: string, backend: Backend, options: { latestFails?: boolean } = {}) {
|
||||
const windowsFont = join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf");
|
||||
if (!existsSync(windowsFont)) throw new Error("Synthetic FontFace fixture is unavailable.");
|
||||
const assetId = backend.canvas.background.asset_id!;
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json" }));
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||
body: JSON.stringify({ canvas_state: backend.canvas, created_at: "2026-08-03T06:00:00.000Z", current_image_id: assetId, images: [], name: "导出画布", project_id: projectId, ratio: backend.canvas.ratio, state_version: backend.version }),
|
||||
contentType: "application/json",
|
||||
}));
|
||||
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
|
||||
backend.canvas = (route.request().postDataJSON() as { canvas_state: CanvasState }).canvas_state;
|
||||
backend.saves += 1;
|
||||
backend.version += 1;
|
||||
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json" });
|
||||
});
|
||||
await page.route(`**/api/v1/projects/${projectId}/latest-exports/*`, async (route) => {
|
||||
backend.latestBodies.push(route.request().postDataBuffer() ?? Buffer.alloc(0));
|
||||
await route.fulfill({ body: options.latestFails ? "null" : JSON.stringify({ status: "saved" }), contentType: "application/json", status: options.latestFails ? 503 : 200 });
|
||||
});
|
||||
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/${assetId}`, (route) => route.fulfill({
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1920"><rect width="100%" height="100%" fill="#39d98a"/><rect x="80" y="100" width="220" height="180" fill="#111111"/></svg>',
|
||||
contentType: "image/svg+xml",
|
||||
}));
|
||||
await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: [] }), contentType: "application/json" }));
|
||||
await page.route("**/api/v1/assets/recent", (route) => route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json" }));
|
||||
await page.route("**/api/v1/assets/public/wp4-fixture-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" }));
|
||||
}
|
||||
|
||||
test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and export", async ({ page }) => {
|
||||
const projectId = "00000000-0000-4000-8000-000000000920";
|
||||
const assetId = "00000000-0000-4000-8000-000000000921";
|
||||
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 };
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
||||
await page.getByLabel("文字内容").fill("尚未提交的导出文字");
|
||||
const downloads: string[] = [];
|
||||
page.on("download", (download) => downloads.push(download.suggestedFilename()));
|
||||
await page.getByRole("button", { name: "导出", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
||||
await expect(dialog).toContainText("导出前需要提交当前修改");
|
||||
await expect(dialog.getByText("将应用当前修改并导出", { exact: true })).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "取消" }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByLabel("文字内容")).toHaveValue("尚未提交的导出文字");
|
||||
expect(backend.saves).toBe(1);
|
||||
expect(backend.latestBodies).toHaveLength(0);
|
||||
expect(downloads).toHaveLength(0);
|
||||
const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version };
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(page.getByLabel("文字内容")).toHaveCount(0);
|
||||
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 });
|
||||
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "春日计划", first_undo_removed_initial_element: true, latest_exports_changed: false });
|
||||
});
|
||||
|
||||
test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes", async ({ page }) => {
|
||||
const projectId = "00000000-0000-4000-8000-000000000930";
|
||||
const assetId = "00000000-0000-4000-8000-000000000931";
|
||||
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 };
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
||||
await page.getByLabel("文字内容").fill("确认后进入导出");
|
||||
await page.getByRole("button", { name: "导出", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
||||
if (process.env.DADA_EVIDENCE_DIR_EXPORT) {
|
||||
const screenshot = resolve(process.env.DADA_EVIDENCE_DIR_EXPORT, "TDD-WP4-EXP-001-confirm-pending-edit", "screenshots", "pending-confirm.png");
|
||||
mkdirSync(dirname(screenshot), { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: screenshot });
|
||||
}
|
||||
await dialog.getByRole("checkbox", { name: "将应用当前修改并导出" }).check();
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await dialog.getByRole("button", { name: "导出并下载" }).click();
|
||||
const download = await downloadPromise;
|
||||
const downloadPath = await download.path();
|
||||
if (!downloadPath) throw new Error("Browser download did not expose a local path.");
|
||||
await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品");
|
||||
await expect.poll(() => backend.saves).toBe(2);
|
||||
expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出");
|
||||
expect(backend.latestBodies).toHaveLength(1);
|
||||
const downloaded = readFileSync(downloadPath);
|
||||
const hash = createHash("sha256").update(downloaded).digest("hex");
|
||||
const multipart = backend.latestBodies[0]!.toString("latin1");
|
||||
expect(multipart).toContain(hash);
|
||||
expect(multipart).toContain('name="pixel_width"\r\n\r\n1080');
|
||||
expect(multipart).toContain('name="pixel_height"\r\n\r\n1440');
|
||||
await dialog.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(page.getByLabel("文字内容")).toHaveValue("春日计划");
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect(page.getByLabel("文字内容")).toHaveValue("确认后进入导出");
|
||||
evidence("TDD-WP4-EXP-001-confirm-pending-edit", "export-hash.json", { download_sha256: hash, latest_multipart_contains_same_sha256: true, mime_type: "image/jpeg" });
|
||||
evidence("TDD-WP4-EXP-001-confirm-pending-edit", "network-timeline.json", { download: "succeeded", latest_save: "after_download", project_save_count_after_confirm: 2, state_version: backend.version });
|
||||
evidence("TDD-WP4-EXP-001-confirm-pending-edit", "undo-trace.json", { after_confirm: "确认后进入导出", after_one_undo: "春日计划", after_one_redo: "确认后进入导出", export_added_history_entry: false });
|
||||
});
|
||||
|
||||
test("TDD-WP4-EXP-001 encodes four exact ratios, PNG losslessly, and JPG qualities 80/92/100 in sRGB", async ({ page }) => {
|
||||
await page.goto(webUrl);
|
||||
const results = await page.evaluate(async () => {
|
||||
const { EXPORT_DIMENSIONS, encodeCanvasExport, resolveExportSettings } = await import("/src/export-compositor.ts");
|
||||
const output: Array<Record<string, unknown>> = [];
|
||||
for (const ratio of ["3:4", "1:1", "4:3", "9:16"] as const) {
|
||||
const dimensions = EXPORT_DIMENSIONS[ratio];
|
||||
for (const [format, quality] of [["jpg", 80], ["jpg", 92], ["jpg", 100], ["png", undefined]] as const) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = dimensions.width;
|
||||
canvas.height = dimensions.height;
|
||||
const context = canvas.getContext("2d", { colorSpace: "srgb" });
|
||||
if (!context) throw new Error("Canvas context unavailable.");
|
||||
context.fillStyle = "#39d98a";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
for (let row = 0; row < 20; row += 1) for (let column = 0; column < 20; column += 1) {
|
||||
context.fillStyle = `rgb(${(row * 37 + column * 11) % 256} ${(row * 17 + column * 43) % 256} ${(row * 29 + column * 23) % 256})`;
|
||||
context.fillRect(column * 22, row * 22, 22, 22);
|
||||
}
|
||||
const settings = resolveExportSettings({ format, quality, ratio });
|
||||
const started = performance.now();
|
||||
const blob = await encodeCanvasExport(canvas, settings);
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const sample = document.createElement("canvas");
|
||||
sample.width = 1; sample.height = 1;
|
||||
const sampleContext = sample.getContext("2d")!;
|
||||
sampleContext.drawImage(bitmap, dimensions.width - 1, dimensions.height - 1, 1, 1, 0, 0, 1, 1);
|
||||
const rgba = [...sampleContext.getImageData(0, 0, 1, 1).data];
|
||||
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
|
||||
const sha256 = [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
output.push({ bytes: blob.size, colorSpace: context.getContextAttributes().colorSpace, durationMs: performance.now() - started, format, height: bitmap.height, quality: settings.quality ?? null, ratio, rgba, sha256, type: blob.type, width: bitmap.width });
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
return output;
|
||||
});
|
||||
expect(results).toHaveLength(16);
|
||||
for (const result of results) {
|
||||
const expected = ({ "1:1": [1080, 1080], "3:4": [1080, 1440], "4:3": [1440, 1080], "9:16": [1080, 1920] } as const)[result.ratio as "1:1" | "3:4" | "4:3" | "9:16"];
|
||||
expect([result.width, result.height]).toEqual(expected);
|
||||
expect(result.colorSpace).toBe("srgb");
|
||||
expect(result.type).toBe(result.format === "png" ? "image/png" : "image/jpeg");
|
||||
if (result.format === "png") expect(result.rgba).toEqual([57, 217, 138, 255]);
|
||||
else {
|
||||
expect(Math.abs((result.rgba as number[])[0]! - 57)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs((result.rgba as number[])[1]! - 217)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs((result.rgba as number[])[2]! - 138)).toBeLessThanOrEqual(3);
|
||||
}
|
||||
const combination = `${String(result.ratio).replace(":", "x")}-${result.format}${result.quality ?? ""}`;
|
||||
evidence("TDD-WP4-EXP-001-format-ratio", `combinations/${combination}/result.json`, { status: "passed" });
|
||||
evidence("TDD-WP4-EXP-001-format-ratio", `combinations/${combination}/image-metadata.json`, { bytes: result.bytes, color_space: result.colorSpace, format: result.format, height: result.height, quality: result.quality, type: result.type, width: result.width });
|
||||
evidence("TDD-WP4-EXP-001-format-ratio", `combinations/${combination}/pixel-diff.json`, { bottom_right_rgba: result.rgba, exact_dimensions: true, no_watermark: true, png_lossless: result.format === "png" ? (result.rgba as number[]).join(",") === "57,217,138,255" : "not_applicable" });
|
||||
evidence("TDD-WP4-EXP-001-format-ratio", `combinations/${combination}/export-hash.json`, { sha256: result.sha256 });
|
||||
}
|
||||
for (const ratio of ["3:4", "1:1", "4:3", "9:16"]) {
|
||||
const sizes = results.filter((item) => item.ratio === ratio && item.format === "jpg").toSorted((left, right) => Number(left.quality) - Number(right.quality)).map((item) => Number(item.bytes));
|
||||
expect(new Set(sizes).size).toBe(3);
|
||||
expect(sizes[2]).toBeGreaterThan(sizes[1]!);
|
||||
expect(sizes[1]).toBeGreaterThan(sizes[0]!);
|
||||
}
|
||||
evidence("TDD-WP4-EXP-001-format-ratio", "performance.json", { maximum_composition_ms: Math.max(...results.map((item) => item.durationMs as number)), samples: results.length });
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { CanvasEditHistory } from "../../apps/web/src/editor-canvas.js";
|
||||
import {
|
||||
EXPORT_DIMENSIONS,
|
||||
completePendingTextForExport,
|
||||
resolveExportSettings,
|
||||
sha256Blob,
|
||||
} from "../../apps/web/src/export-compositor.js";
|
||||
import { P0A_TEXT_TEMPLATES, TextEditSession, createTextTemplateElement } from "../../apps/web/src/text-assets.js";
|
||||
|
||||
function canvasWithText(): CanvasState {
|
||||
return {
|
||||
background: {
|
||||
adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 },
|
||||
asset_id: null,
|
||||
},
|
||||
elements: [createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, {
|
||||
createdAt: "2026-08-03T06:00:00.000Z",
|
||||
elementId: "00000000-0000-4000-8000-000000000901",
|
||||
}, 0)],
|
||||
pixel_height: 1440,
|
||||
pixel_width: 1080,
|
||||
ratio: "3:4",
|
||||
schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TASK-WP4-05 export compositor contract", () => {
|
||||
it("freezes exact output pixels for all four ratios", () => {
|
||||
expect(EXPORT_DIMENSIONS).toEqual({
|
||||
"1:1": { height: 1080, width: 1080 },
|
||||
"3:4": { height: 1440, width: 1080 },
|
||||
"4:3": { height: 1080, width: 1440 },
|
||||
"9:16": { height: 1920, width: 1080 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses JPG 92 by default, accepts only 80-100, and removes PNG quality", () => {
|
||||
expect(resolveExportSettings({ format: "jpg", ratio: "3:4" })).toEqual({
|
||||
format: "jpg", height: 1440, mimeType: "image/jpeg", quality: 92, width: 1080,
|
||||
});
|
||||
expect(resolveExportSettings({ format: "jpg", quality: 80, ratio: "1:1" }).quality).toBe(80);
|
||||
expect(resolveExportSettings({ format: "jpg", quality: 100, ratio: "4:3" }).quality).toBe(100);
|
||||
expect(resolveExportSettings({ format: "png", quality: 80, ratio: "9:16" })).toEqual({
|
||||
format: "png", height: 1920, mimeType: "image/png", width: 1080,
|
||||
});
|
||||
expect(() => resolveExportSettings({ format: "jpg", quality: 79, ratio: "3:4" })).toThrowError("export_quality_invalid");
|
||||
expect(() => resolveExportSettings({ format: "jpg", quality: 100.5, ratio: "3:4" })).toThrowError("export_quality_invalid");
|
||||
});
|
||||
|
||||
it("turns one pending text draft into exactly one history operation", () => {
|
||||
const before = canvasWithText();
|
||||
const draftSession = new TextEditSession(before.elements[0]!, P0A_TEXT_TEMPLATES);
|
||||
draftSession.setContent("待提交导出文字");
|
||||
const draft = draftSession.value;
|
||||
const history = new CanvasEditHistory(before);
|
||||
|
||||
const confirmed = completePendingTextForExport(before, draft);
|
||||
history.commit(confirmed);
|
||||
|
||||
expect(confirmed.elements[0]?.content).toBe("待提交导出文字");
|
||||
expect(before.elements[0]?.content).toBe("春日计划");
|
||||
expect(history.undo()?.elements[0]?.content).toBe("春日计划");
|
||||
expect(history.redo()?.elements[0]?.content).toBe("待提交导出文字");
|
||||
});
|
||||
|
||||
it("hashes the exact Blob bytes used by download and latest persistence", async () => {
|
||||
const blob = new Blob(["same-export-blob"], { type: "image/png" });
|
||||
expect(await sha256Blob(blob)).toBe("ef710020af56830015263fa74537d28601fd0fb03f4a69d1cde63ff8722a9fd6");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user