From d09fda13c5e7b571629b526dc4d2768d2c41c0d6 Mon Sep 17 00:00:00 2001 From: suyx Date: Mon, 3 Aug 2026 14:41:04 +0800 Subject: [PATCH] feat: complete TASK-WP4-05 client export --- apps/web/src/editor-page.css | 38 ++++ apps/web/src/editor-page.tsx | 109 +++++++++- apps/web/src/editor-stage.tsx | 95 +++++++-- apps/web/src/export-compositor.ts | 118 +++++++++++ apps/web/src/export-dialog.tsx | 38 ++++ package.json | 6 +- scripts/run-wp4-05-validation.mjs | 88 ++++++++ tests/e2e/wp4-05-export.spec.ts | 214 ++++++++++++++++++++ tests/unit/wp4-05-export-compositor.test.ts | 73 +++++++ 9 files changed, 753 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/export-compositor.ts create mode 100644 apps/web/src/export-dialog.tsx create mode 100644 scripts/run-wp4-05-validation.mjs create mode 100644 tests/e2e/wp4-05-export.spec.ts create mode 100644 tests/unit/wp4-05-export-compositor.test.ts diff --git a/apps/web/src/editor-page.css b/apps/web/src/editor-page.css index ddda837..d6bccee 100644 --- a/apps/web/src/editor-page.css +++ b/apps/web/src/editor-page.css @@ -463,6 +463,44 @@ .editor-location-consent input { min-height: 40px; padding: 7px 9px; border: 1px solid #85857f; border-radius: 0; font: inherit; } .editor-location-actions { display: grid !important; grid-template-columns: 1fr; } +.editor-export-dialog { + width: min(640px, 100%); + max-height: min(760px, calc(100vh - 48px)); + overflow: auto; + border: 2px solid #111111; + background: #ffffff; + box-shadow: 8px 8px 0 #111111; +} +.editor-export-dialog > header { display: flex; min-height: 82px; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid #b9b9b3; } +.editor-export-dialog > header p { margin: 0 0 3px; color: #62625d; font-size: 10px; font-weight: 800; } +.editor-export-dialog > header h2 { margin: 0; font-size: 22px; } +.editor-export-dialog > header button { width: 36px; height: 36px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: 24px/1 Arial, sans-serif; } +.editor-export-section { padding: 18px 22px; border-bottom: 1px solid #b9b9b3; } +.editor-export-section h3 { margin: 0 0 10px; font-size: 13px; } +.editor-export-segments { display: grid; grid-template-columns: 1fr 1fr; } +.editor-export-segments button { min-height: 42px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 800; } +.editor-export-segments button + button { border-left: 0; } +.editor-export-segments button[aria-pressed="true"] { background: #111111; color: #ffffff; } +.editor-export-label { display: flex; align-items: center; justify-content: space-between; } +.editor-export-label h3 { margin: 0; } +.editor-export-label output { min-width: 36px; font-weight: 800; text-align: right; } +.editor-export-quality { display: grid; grid-template-columns: minmax(0, 1fr) 72px; gap: 14px; align-items: center; margin-top: 10px; } +.editor-export-quality input[type="number"] { min-width: 0; height: 38px; padding: 5px 7px; border: 1px solid #85857f; border-radius: 0; font: inherit; } +.editor-export-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 20px; padding: 18px 22px; border-bottom: 1px solid #b9b9b3; font-size: 12px; } +.editor-export-summary span { color: #62625d; } +.editor-export-summary strong { text-align: right; } +.editor-export-pending { display: grid; gap: 10px; margin: 18px 22px 0; padding: 14px; border-left: 4px solid #c92a24; background: #f7f7f4; font-size: 12px; } +.editor-export-pending span { display: flex; gap: 9px; align-items: center; } +.editor-export-pending input { width: 18px; height: 18px; margin: 0; } +.editor-export-dialog > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 20px 22px; } +.editor-export-dialog > footer button, +.editor-export-result button { min-height: 42px; padding: 8px 16px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 800; } +.editor-export-result { display: grid; gap: 12px; padding: 28px 22px 22px; } +.editor-export-result > p { margin: 0; padding: 14px; border-left: 4px solid #12805c; background: #effaf5; font-weight: 800; } +.editor-export-result.failed > p { border-left-color: #c92a24; background: #fff2f0; color: #8f1d14; } +.editor-export-result small { color: #62625d; } +.editor-export-result > div { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } + .editor-loading { display: grid; min-height: 100vh; place-items: center; background: #e8e8e5; } @media (max-width: 1100px) { diff --git a/apps/web/src/editor-page.tsx b/apps/web/src/editor-page.tsx index 46fdc57..eb56b26 100644 --- a/apps/web/src/editor-page.tsx +++ b/apps/web/src/editor-page.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts"; -import { ProjectAutoSaveQueue, type ProjectSaveStatus } from "./project-autosave.js"; +import { ConflictExportGuard, ProjectAutoSaveQueue, type ProjectSaveStatus } from "./project-autosave.js"; import { CanvasEditHistory, createEditorCanvasState, @@ -12,6 +12,17 @@ import { } from "./editor-canvas.js"; import { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.js"; import { EditorStage } from "./editor-stage.js"; +import { ExportDialog } from "./export-dialog.js"; +import { + completePendingTextForExport, + composeCanvasExport, + downloadExportBlob, + exportFilename, + resolveExportSettings, + saveLatestExport, + type ExportFormat, +} from "./export-compositor.js"; +import { runExportFlow, type ExportFlowStatus } from "./export-flow.js"; import { TextInspector } from "./text-inspector.js"; import { createBrowserArchivedFontLoader, type ArchivedFontLoader, type ArchivedFontStatus } from "./text-font-loader.js"; import { @@ -66,6 +77,13 @@ interface LocationDialogState { pending: boolean; } +interface EditorExportResult { + blob: Blob | null; + format: ExportFormat; + quality?: number; + status: ExportFlowStatus; +} + interface EditorProject { canvas_state?: CanvasState; created_at: string; @@ -125,6 +143,9 @@ export function EditorPage({ projectId }: { projectId: string }) { const [fontStatuses, setFontStatuses] = useState>({}); const [textEdit, setTextEdit] = useState(); const [locationDialog, setLocationDialog] = useState(); + const [exportOpen, setExportOpen] = useState(false); + const [exportBusy, setExportBusy] = useState(false); + const [exportResult, setExportResult] = useState(); const queueRef = useRef(undefined); const historyRef = useRef(undefined); const elementControllerRef = useRef(undefined); @@ -132,6 +153,7 @@ export function EditorPage({ projectId }: { projectId: string }) { const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined); const clipboardRef = useRef([]); const fontLoaderRef = useRef(undefined); + const conflictExportGuardRef = useRef(new ConflictExportGuard()); useEffect(() => { let active = true; @@ -515,6 +537,79 @@ export function EditorPage({ projectId }: { projectId: string }) { setNotice("已取消未提交的文字修改"); } + function pendingTextDraft() { + if (!canvasState || !textEdit) return undefined; + const committed = canvasState.elements.find((element) => element.element_id === textEdit.elementId); + return committed && JSON.stringify(committed) !== JSON.stringify(textEdit.draft) ? textEdit : undefined; + } + + function openExportDialog() { + setExportResult(undefined); + setExportOpen(true); + } + + async function executeExport(options: { format: ExportFormat; quality?: number }, reuseBlob?: Blob) { + if (!canvasState || !project || !session) return; + setExportBusy(true); + try { + let exportState = canvasState; + const pending = reuseBlob ? undefined : pendingTextDraft(); + if (pending) { + exportState = completePendingTextForExport(canvasState, pending.draft); + commitCanvas(exportState); + const completed = exportState.elements.find((element) => element.element_id === pending.elementId); + if (completed && completed.template_or_asset_id !== pending.originalTemplateId) { + void recordRecentTextTemplate(completed.template_or_asset_id, completed.resource_version); + } + } + + const queue = queueRef.current; + const projectSaved = saveStatus !== "conflicted" && Boolean(queue && await queue.saveNow()); + const settings = resolveExportSettings({ format: options.format, ratio: exportState.ratio, ...(options.quality === undefined ? {} : { quality: options.quality }) }); + const result = await runExportFlow({ + compose: async () => reuseBlob ?? composeCanvasExport({ + canvasState: exportState, + fontStatuses, + format: options.format, + projectId, + ...(options.quality === undefined ? {} : { quality: options.quality }), + }), + download: (blob) => downloadExportBlob(blob, exportFilename(project.name, options.format)), + persist: async (blob) => { + if (!projectSaved || !queue) throw new Error("project_state_not_saved"); + await saveLatestExport({ + blob, + csrfToken: session.csrf_token, + format: options.format, + height: settings.height, + projectId, + stateVersion: queue.stateVersion, + width: settings.width, + }); + }, + }); + setExportResult({ blob: result.blob, format: options.format, status: result.status, ...(options.quality === undefined ? {} : { quality: options.quality }) }); + } catch { + setExportResult({ blob: null, format: options.format, status: "composition_failed", ...(options.quality === undefined ? {} : { quality: options.quality }) }); + } finally { + setExportBusy(false); + } + } + + async function startExport(options: { format: ExportFormat; quality?: number }) { + if (saveStatus !== "conflicted") { + await executeExport(options); + return; + } + const ran = await conflictExportGuardRef.current.run(() => executeExport(options)); + if (!ran) setNotice("版本冲突时仅允许导出本页版本一次"); + } + + async function retryExportDownload() { + if (!exportResult?.blob) return; + await executeExport({ format: exportResult.format, ...(exportResult.quality === undefined ? {} : { quality: exportResult.quality }) }, exportResult.blob); + } + function transformSelection(action: (controller: CanvasElementController) => void, message: string) { const controller = controllerForCurrent(); if (!controller || selectedIds.length === 0) return; @@ -666,7 +761,7 @@ export function EditorPage({ projectId }: { projectId: string }) { {saveStatusLabel(saveStatus)} - + {notice ?

{notice}

: null}
@@ -795,6 +890,16 @@ export function EditorPage({ projectId }: { projectId: string }) {
: null} + {exportOpen ? { if (!exportBusy) { setExportOpen(false); setExportResult(undefined); } }} + onExport={(options) => { void startExport(options); }} + onRetry={() => { void retryExportDownload(); }} + pendingEdit={Boolean(pendingTextDraft())} + ratio={canvasState.ratio} + {...(exportResult?.blob ? { byteSize: exportResult.blob.size } : {})} + {...(exportResult ? { result: exportResult.status } : {})} + /> : null} ); } diff --git a/apps/web/src/editor-stage.tsx b/apps/web/src/editor-stage.tsx index 8082f74..6f6d0c0 100644 --- a/apps/web/src/editor-stage.tsx +++ b/apps/web/src/editor-stage.tsx @@ -240,6 +240,76 @@ function loadCanvasImage(url: string) { }); } +function resourceUrlsForCanvas(canvasState: CanvasState) { + const imageReferences = new Map(); + for (const element of canvasState.elements) { + if (element.type === "static_sticker") imageReferences.set(element.template_or_asset_id, dynamicImageUrl(element.resource_version, element.template_or_asset_id)); + if (element.type === "dynamic_sticker") { + const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId]; + for (const layer of model?.imageLayers ?? []) imageReferences.set(layer.assetId, dynamicImageUrl(element.resource_version, layer.assetId)); + } + } + return imageReferences; +} + +async function loadSceneResources(canvasState: CanvasState, projectId: string) { + const background = canvasState.background.asset_id + ? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`) + : Promise.resolve(undefined); + const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const)); + const [image, loaded] = await Promise.all([background, resources]); + return { + background: image, + resourceImages: Object.fromEntries(loaded.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined)), + }; +} + +function renderEditorScene( + context: CanvasRenderingContext2D, + canvasState: CanvasState, + fontStatuses: Readonly>, + background: HTMLImageElement | undefined, + resourceImages: Readonly>, +) { + context.clearRect(0, 0, canvasState.pixel_width, canvasState.pixel_height); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvasState.pixel_width, canvasState.pixel_height); + context.filter = cssFilterForBackground(canvasState.background.adjustments); + if (background) context.drawImage(background, 0, 0, canvasState.pixel_width, canvasState.pixel_height); + context.filter = "none"; + for (const element of [...canvasState.elements].sort((left, right) => left.z_index - right.z_index)) { + drawElement(context, element, canvasState.pixel_width, canvasState.pixel_height, fontStatuses, resourceImages); + } +} + +function requiredFontIds(canvasState: CanvasState) { + return canvasState.elements.flatMap((element) => { + if (element.type === "text_template") return [fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId)); + if (element.type === "dynamic_sticker") return dynamicFontOptionsFor(element.template_or_asset_id).map((font) => font.fontId); + return []; + }); +} + +export async function renderCanvasStateForExport(input: { + canvasState: CanvasState; + fontStatuses: Readonly>; + projectId: string; +}) { + const missingFont = requiredFontIds(input.canvasState).find((fontId) => input.fontStatuses[fontId] !== "ready"); + if (missingFont) throw new Error("export_font_unavailable"); + await document.fonts.ready; + const resources = await loadSceneResources(input.canvasState, input.projectId); + if (input.canvasState.background.asset_id && !resources.background) throw new Error("export_background_unavailable"); + if (Object.keys(resources.resourceImages).length !== resourceUrlsForCanvas(input.canvasState).size) throw new Error("export_asset_unavailable"); + const canvas = document.createElement("canvas"); + canvas.width = input.canvasState.pixel_width; + canvas.height = input.canvasState.pixel_height; + const context = canvas.getContext("2d", { colorSpace: "srgb" }); + if (!context) throw new Error("export_canvas_unavailable"); + renderEditorScene(context, input.canvasState, input.fontStatuses, resources.background, resources.resourceImages); + return canvas; +} + export function EditorStage(props: EditorStageProps) { const canvasRef = useRef(null); const gestureRef = useRef(undefined); @@ -255,13 +325,7 @@ export function EditorStage(props: EditorStageProps) { const context = canvas.getContext("2d"); if (!context) return undefined; const render = (image: HTMLImageElement | undefined, resourceImages: Readonly>) => { - context.clearRect(0, 0, canvas.width, canvas.height); - context.fillStyle = "#ffffff"; - context.fillRect(0, 0, canvas.width, canvas.height); - context.filter = cssFilterForBackground(props.canvasState.background.adjustments); - if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height); - context.filter = "none"; - for (const element of [...props.canvasState.elements].sort((left, right) => left.z_index - right.z_index)) drawElement(context, element, canvas.width, canvas.height, props.fontStatuses, resourceImages); + renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages); context.lineWidth = 4; context.strokeStyle = "#005fcc"; for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) { @@ -281,22 +345,9 @@ export function EditorStage(props: EditorStageProps) { if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height); context.restore(); }; - const background = props.assetId - ? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`) - : Promise.resolve(undefined); - const imageReferences = new Map(); - for (const element of props.canvasState.elements) { - if (element.type === "static_sticker") imageReferences.set(element.template_or_asset_id, dynamicImageUrl(element.resource_version, element.template_or_asset_id)); - if (element.type === "dynamic_sticker") { - const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId]; - for (const layer of model?.imageLayers ?? []) imageReferences.set(layer.assetId, dynamicImageUrl(element.resource_version, layer.assetId)); - } - } - const dynamic = Promise.all([...imageReferences].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const)); - void Promise.all([background, dynamic]).then(([image, loaded]) => { + void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => { if (!active) return; - const resourceImages = Object.fromEntries(loaded.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined)); - render(image, resourceImages); + render(background, resourceImages); }); return () => { active = false; }; }, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]); diff --git a/apps/web/src/export-compositor.ts b/apps/web/src/export-compositor.ts new file mode 100644 index 0000000..2a3b3f4 --- /dev/null +++ b/apps/web/src/export-compositor.ts @@ -0,0 +1,118 @@ +import type { CanvasState } from "@dada/shared-contracts"; + +import { renderCanvasStateForExport } from "./editor-stage.js"; +import { P0A_TEXT_TEMPLATES, TextEditSession } from "./text-assets.js"; +import type { ArchivedFontStatus } from "./text-font-loader.js"; + +export type ExportFormat = "jpg" | "png"; +export type ExportRatio = CanvasState["ratio"]; + +export const EXPORT_DIMENSIONS: Readonly> = { + "1:1": { height: 1080, width: 1080 }, + "3:4": { height: 1440, width: 1080 }, + "4:3": { height: 1080, width: 1440 }, + "9:16": { height: 1920, width: 1080 }, +}; + +export interface ExportSettings { + format: ExportFormat; + height: number; + mimeType: "image/jpeg" | "image/png"; + quality?: number; + width: number; +} + +export function resolveExportSettings(input: { format: ExportFormat; quality?: number; ratio: ExportRatio }): ExportSettings { + const dimensions = EXPORT_DIMENSIONS[input.ratio]; + if (input.format === "png") return { format: "png", height: dimensions.height, mimeType: "image/png", width: dimensions.width }; + const quality = input.quality ?? 92; + if (!Number.isInteger(quality) || quality < 80 || quality > 100) throw new Error("export_quality_invalid"); + return { format: "jpg", height: dimensions.height, mimeType: "image/jpeg", quality, width: dimensions.width }; +} + +export function completePendingTextForExport(canvasState: CanvasState, draft: CanvasState["elements"][number]) { + if (draft.type !== "text_template") throw new Error("export_pending_text_invalid"); + const index = canvasState.elements.findIndex((element) => element.element_id === draft.element_id && element.type === "text_template"); + if (index < 0) throw new Error("export_pending_text_missing"); + const complete = new TextEditSession(draft, P0A_TEXT_TEMPLATES).complete(); + const next = structuredClone(canvasState); + next.elements[index] = complete; + return next; +} + +export async function sha256Blob(blob: Blob) { + const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()); + return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join(""); +} + +export function encodeCanvasExport(canvas: HTMLCanvasElement, settings: ExportSettings) { + if (canvas.width !== settings.width || canvas.height !== settings.height) throw new Error("export_canvas_dimensions_invalid"); + return new Promise((resolve, reject) => { + const quality = settings.format === "jpg" ? settings.quality! / 100 : undefined; + canvas.toBlob((blob) => { + if (!blob || blob.type !== settings.mimeType) reject(new Error("export_encode_failed")); + else resolve(blob); + }, settings.mimeType, quality); + }); +} + +export async function composeCanvasExport(input: { + canvasState: CanvasState; + fontStatuses: Readonly>; + format: ExportFormat; + projectId: string; + quality?: number; +}) { + const settings = resolveExportSettings({ format: input.format, ratio: input.canvasState.ratio, ...(input.quality === undefined ? {} : { quality: input.quality }) }); + if (input.canvasState.pixel_width !== settings.width || input.canvasState.pixel_height !== settings.height) throw new Error("export_canvas_dimensions_invalid"); + const canvas = await renderCanvasStateForExport({ canvasState: input.canvasState, fontStatuses: input.fontStatuses, projectId: input.projectId }); + return encodeCanvasExport(canvas, settings); +} + +export async function downloadExportBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + try { + const anchor = document.createElement("a"); + anchor.download = filename; + anchor.href = url; + anchor.style.display = "none"; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + } finally { + setTimeout(() => URL.revokeObjectURL(url), 0); + } +} + +export function exportFilename(projectName: string, format: ExportFormat) { + const safeName = projectName.trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_").slice(0, 80) || "Dada成品"; + return `${safeName}.${format === "jpg" ? "jpg" : "png"}`; +} + +export async function saveLatestExport(input: { + blob: Blob; + csrfToken: string; + format: ExportFormat; + height: number; + projectId: string; + stateVersion: number; + width: number; +}) { + const sha256 = await sha256Blob(input.blob); + const form = new FormData(); + form.append("format", input.format); + form.append("export_id", crypto.randomUUID()); + form.append("sha256", sha256); + form.append("byte_size", String(input.blob.size)); + form.append("pixel_width", String(input.width)); + form.append("pixel_height", String(input.height)); + form.append("state_version", String(input.stateVersion)); + form.append("export_file", input.blob, `latest.${input.format}`); + const response = await fetch(`/api/v1/projects/${encodeURIComponent(input.projectId)}/latest-exports/${input.format}`, { + body: form, + credentials: "same-origin", + headers: { "X-CSRF-Token": input.csrfToken }, + method: "PUT", + }); + if (!response.ok) throw new Error(response.status === 409 ? "latest_export_conflict" : "latest_export_failed"); +} diff --git a/apps/web/src/export-dialog.tsx b/apps/web/src/export-dialog.tsx new file mode 100644 index 0000000..cd2fbf0 --- /dev/null +++ b/apps/web/src/export-dialog.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; + +import { exportResultCopy, type ExportFlowStatus } from "./export-flow.js"; +import { EXPORT_DIMENSIONS, type ExportFormat, type ExportRatio } from "./export-compositor.js"; + +export function ExportDialog(props: { + busy: boolean; + byteSize?: number; + onClose: () => void; + onExport: (input: { format: ExportFormat; quality?: number }) => void; + onRetry: () => void; + pendingEdit: boolean; + ratio: ExportRatio; + result?: ExportFlowStatus; +}) { + const [format, setFormat] = useState("jpg"); + const [quality, setQuality] = useState(92); + const [pendingConfirmed, setPendingConfirmed] = useState(false); + const dimensions = EXPORT_DIMENSIONS[props.ratio]; + const failure = props.result && props.result !== "downloaded_and_saved"; + return
+

EXPORT

导出成品

+ {props.result ?
+

{exportResultCopy[props.result]}

+ {props.byteSize ? {(props.byteSize / 1024 / 1024).toFixed(2)} MB : null} +
{props.result === "download_failed" ? : null}
+
: <> +

文件格式

+ + +
+ {format === "jpg" ?

JPG 质量

{quality}
setQuality(Number(event.target.value))} step="1" type="range" value={quality} /> setQuality(Math.max(80, Math.min(100, Number(event.target.value))))} step="1" type="number" value={quality} />
: null} +
输出尺寸{dimensions.width} × {dimensions.height} px色彩空间sRGB预计文件大小合成后显示
+ {props.pendingEdit ? : null} +
+ } +
; +} diff --git a/package.json b/package.json index 179ae0e..dafc6e5 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -81,7 +81,9 @@ "test:wp4-03": "node scripts/run-wp4-03-validation.mjs", "test:wp4-03:red": "node scripts/run-wp4-03-validation.mjs --phase red", "test:wp4-04": "node scripts/run-wp4-04-validation.mjs", - "test:wp4-04:red": "node scripts/run-wp4-04-validation.mjs --phase red" + "test:wp4-04:red": "node scripts/run-wp4-04-validation.mjs --phase red", + "test:wp4-05": "node scripts/run-wp4-05-validation.mjs", + "test:wp4-05:red": "node scripts/run-wp4-05-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/scripts/run-wp4-05-validation.mjs b/scripts/run-wp4-05-validation.mjs new file mode 100644 index 0000000..147469e --- /dev/null +++ b/scripts/run-wp4-05-validation.mjs @@ -0,0 +1,88 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const phaseIndex = process.argv.indexOf("--phase"); +const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green"; +if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp4-05-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const casesDirectory = resolve(runDirectory, "cases"); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +mkdirSync(casesDirectory, { recursive: true }); + +const combinations = ["3x4", "1x1", "4x3", "9x16"].flatMap((ratio) => ["jpg80", "jpg92", "jpg100", "png"].flatMap((format) => ["result.json", "image-metadata.json", "pixel-diff.json", "export-hash.json"].map((file) => `combinations/${ratio}-${format}/${file}`))); +const cases = [ + { id: "TDD-WP4-EXP-001-cancel-pending-edit", evidence: ["network-timeline.json", "db-diff.json", "trace.zip"] }, + { id: "TDD-WP4-EXP-001-confirm-pending-edit", evidence: ["undo-trace.json", "network-timeline.json", "export-hash.json", "trace.zip", "screenshots/pending-confirm.png"] }, + { id: "TDD-WP4-EXP-001-format-ratio", evidence: [...combinations, "performance.json"] }, +]; +for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true }); + +const outputDirectory = resolve(runDirectory, "playwright-output"); +const environment = { ...process.env, DADA_EVIDENCE_DIR_EXPORT: casesDirectory, DADA_PLAYWRIGHT_OUTPUT_DIR: outputDirectory }; +const commands = phase === "red" ? [] : [ + ["unit", "pnpm test:unit"], + ["e2e", "pnpm test:e2e"], + ["visual", "pnpm test:visual"], + ["performance", "pnpm test:performance"], + ["tdd-trace", "pnpm validate:tdd-trace"], +]; +const commandResults = []; +for (const [name, command] of commands) { + const started_at = new Date().toISOString(); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at }); +} + +function traces(directory) { + if (!existsSync(directory)) return []; + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? traces(path) : entry.name === "trace.zip" ? [path] : []; + }); +} +if (phase === "green") { + const found = traces(outputDirectory); + const needles = ["outside-history-and-export", "saves-the-same-bytes"]; + needles.forEach((needle, index) => { + const trace = found.find((path) => path.includes(needle)); + if (trace) copyFileSync(trace, resolve(casesDirectory, cases[index].id, "trace.zip")); + }); +} else { + const observation = { + expected_failure: "TASK-WP4-05 export compositor and enabled editor export entry did not exist", + observed_command: "pnpm vitest run tests/unit/wp4-05-export-compositor.test.ts", + observed_error: "Cannot find module ../../apps/web/src/export-compositor.js", + status: "red_confirmed", + }; + for (const item of cases) writeFileSync(resolve(casesDirectory, item.id, "red-observation.json"), `${JSON.stringify(observation, null, 2)}\n`); +} + +const commandState = phase === "red" || commandResults.every((result) => result.exit_code === 0); +const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }; +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0; +const summaries = cases.map((item) => { + const directory = resolve(casesDirectory, item.id); + const refs = phase === "red" ? ["red-observation.json"] : item.evidence; + const missing = refs.filter((file) => !existsSync(resolve(directory, file))); + const status = commandState && missing.length === 0 ? phase === "red" ? "red_confirmed" : "passed" : "failed"; + writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); + writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({ + acceptance_criteria: ["AC-23"], automation: ["automated"], commit, evidence_refs: refs, + layer: ["UNIT", "E2E", "VIS-PERF"], manifest, missing_evidence: missing, phase, + requirements: ["EXPORT-01", "EXPORT-02", "EXPORT-03", "PROJECT-04", "PROJECT-06"], run_id: runId, + status, task_id: "TASK-WP4-05", test_id: item.id, work_package: "WP-4", + worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation", + }, null, 2)}\n`); + return { missing_evidence: missing, status, test_id: item.id }; +}); +const status = summaries.every((item) => item.status === (phase === "red" ? "red_confirmed" : "passed")) ? phase === "red" ? "red_confirmed" : "passed" : "failed"; +writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)}\n`); +console.log(JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)); +if (status === "failed") process.exit(1); diff --git a/tests/e2e/wp4-05-export.spec.ts b/tests/e2e/wp4-05-export.spec.ts new file mode 100644 index 0000000..91d780d --- /dev/null +++ b/tests/e2e/wp4-05-export.spec.ts @@ -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: '', + 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> = []; + 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 }); +}); diff --git a/tests/unit/wp4-05-export-compositor.test.ts b/tests/unit/wp4-05-export-compositor.test.ts new file mode 100644 index 0000000..4d625f9 --- /dev/null +++ b/tests/unit/wp4-05-export-compositor.test.ts @@ -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"); + }); +});