From e4aec01ea6511285b70c74dbcf2b3a4cced7aa49 Mon Sep 17 00:00:00 2001 From: suyx Date: Wed, 5 Aug 2026 18:47:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(POSTV1-08):=20=E4=BF=AE=E5=A4=8D=E6=96=87?= =?UTF-8?q?=E5=AD=97=E6=8B=96=E5=8A=A8=E9=97=AA=E7=83=81=E4=B8=8E=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/editor-page.tsx | 96 +++++++++++++++++++++--- apps/web/src/editor-stage.tsx | 71 ++++++++++++++---- tests/e2e/wp4-02-editor-elements.spec.ts | 88 ++++++++++++++++++++++ tests/e2e/wp4-05-export.spec.ts | 25 +++--- 4 files changed, 244 insertions(+), 36 deletions(-) diff --git a/apps/web/src/editor-page.tsx b/apps/web/src/editor-page.tsx index 1ef8da0..ecefda4 100644 --- a/apps/web/src/editor-page.tsx +++ b/apps/web/src/editor-page.tsx @@ -86,6 +86,14 @@ interface EditorExportResult { status: ExportFlowStatus; } +function withTextDraft(canvasState: CanvasState, textEdit: TextEditState | undefined) { + if (!textEdit) return canvasState; + return { + ...canvasState, + elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element), + }; +} + interface EditorProject { canvas_state?: CanvasState; created_at: string; @@ -155,6 +163,7 @@ export function EditorPage({ projectId }: { projectId: string }) { const elementControllerRef = useRef(undefined); const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined); const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined); + const textHistoryRef = useRef<{ base: CanvasState; elementId: string; last: CanvasState } | undefined>(undefined); const clipboardRef = useRef([]); const fontLoaderRef = useRef(undefined); const conflictExportGuardRef = useRef(new ConflictExportGuard()); @@ -275,6 +284,10 @@ export function EditorPage({ projectId }: { projectId: string }) { }); }, [canvasState, selectedIds.join("|")]); + useEffect(() => { + if (textEdit) commitTextDraftAutomatically(textEdit); + }, [textEdit?.draft]); + async function ensureFont(fontId: string, url: string, retry = false) { const current = fontStatuses[fontId]; if (current === "ready" || (current === "unavailable" && !retry)) return current; @@ -291,15 +304,24 @@ export function EditorPage({ projectId }: { projectId: string }) { await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true))); } - function commitCanvas(next: CanvasState) { + function finalizeTextHistory() { + const pending = textHistoryRef.current; + if (!pending) return undefined; + textHistoryRef.current = undefined; + historyRef.current?.commit(pending.last); + return pending.last; + } + + function commitCanvas(next: CanvasState, options: { preserveTextEdit?: boolean } = {}) { if (!project || saveStatus === "conflicted") return; - historyRef.current?.commit(next); + const finalizedText = finalizeTextHistory(); + if (!finalizedText || JSON.stringify(finalizedText) !== JSON.stringify(next)) historyRef.current?.commit(next); elementControllerRef.current?.replaceState(next); setSelectedIds(elementControllerRef.current?.selectedIds ?? []); setCanvasState(next); setDraftAdjustments(next.background.adjustments); queueRef.current?.commit({ canvas_state: next, name: project.name }); - setTextEdit(undefined); + if (!options.preserveTextEdit) setTextEdit(undefined); } function applyPreview() { @@ -309,6 +331,7 @@ export function EditorPage({ projectId }: { projectId: string }) { } function undo() { + finalizeTextHistory(); const previous = historyRef.current?.undo(); if (previous) { elementControllerRef.current?.replaceState(previous); @@ -321,6 +344,7 @@ export function EditorPage({ projectId }: { projectId: string }) { } function redo() { + finalizeTextHistory(); const next = historyRef.current?.redo(); if (next) { elementControllerRef.current?.replaceState(next); @@ -523,6 +547,37 @@ export function EditorPage({ projectId }: { projectId: string }) { }); } + function commitTextDraftAutomatically(editState: TextEditState) { + if (!canvasState || !project || saveStatus === "conflicted") return; + const index = canvasState.elements.findIndex((element) => element.element_id === editState.elementId); + if (index < 0 || JSON.stringify(canvasState.elements[index]) === JSON.stringify(editState.draft)) return; + try { + const complete = new TextEditSession(editState.draft, P0A_TEXT_TEMPLATES).complete(); + const next = structuredClone(canvasState); + next.elements[index] = complete; + const history = textHistoryRef.current; + if (!history || history.elementId !== editState.elementId) { + if (history) finalizeTextHistory(); + textHistoryRef.current = { base: canvasState, elementId: editState.elementId, last: next }; + } else { + history.last = next; + } + elementControllerRef.current?.replaceState(next); + setCanvasState(next); + queueRef.current?.commit({ canvas_state: next, name: project.name }); + if (complete.template_or_asset_id !== editState.originalTemplateId) { + void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version); + } + setTextEdit((current) => current?.elementId === editState.elementId ? { + ...current, + draft: complete, + originalTemplateId: complete.template_or_asset_id, + } : current); + } catch (error) { + if (!(error instanceof Error && error.message === "text_content_required")) showNotice("文字编辑未能自动保存"); + } + } + async function changeTextTemplate(templateId: string) { const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId); if (!template?.fontUrl) return; @@ -549,6 +604,11 @@ export function EditorPage({ projectId }: { projectId: string }) { function completeTextEdit() { if (!textEdit) return; + if (!pendingTextDraft()) { + finalizeTextHistory(); + showNotice("文字修改已进入自动保存"); + return; + } try { const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES); const complete = edit.complete(); @@ -566,8 +626,16 @@ export function EditorPage({ projectId }: { projectId: string }) { } function cancelTextEdit() { + const pendingHistory = textHistoryRef.current; + if (pendingHistory && project) { + textHistoryRef.current = undefined; + elementControllerRef.current?.replaceState(pendingHistory.base); + setCanvasState(pendingHistory.base); + queueRef.current?.commit({ canvas_state: pendingHistory.base, name: project.name }); + } if (canvasState && textEdit) { - const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId); + const source = pendingHistory?.base ?? canvasState; + const current = source.elements.find((element) => element.element_id === textEdit.elementId); if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id }); } showNotice("已取消未提交的文字修改"); @@ -717,13 +785,15 @@ export function EditorPage({ projectId }: { projectId: string }) { } function selectAt(point: CanvasPoint, append: boolean) { + finalizeTextHistory(); const controller = controllerForCurrent(); if (!controller || !canvasState) return false; const candidates = controller.candidatesAt(point); const selection = controller.selectAt(point, { append: append || multiMode }); setSelectedIds(selection); setCandidateMenu(undefined); - dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection }; + const dragBase = withTextDraft(canvasState, textEdit); + dragRef.current = { base: dragBase, last: dragBase, selectedIds: selection }; return candidates.length > 0; } @@ -735,19 +805,25 @@ export function EditorPage({ projectId }: { projectId: string }) { const preview = previewController.moveSelected(delta); drag.last = preview.state; setCanvasState(preview.state); + setTextEdit((current) => { + if (!current || !drag.selectedIds.includes(current.elementId)) return current; + const movedDraft = preview.state.elements.find((element) => element.element_id === current.elementId); + return movedDraft ? { ...current, draft: movedDraft } : current; + }); setGuides(preview.guides); } function commitMove() { const drag = dragRef.current; if (!drag) return; - commitCanvas(drag.last); + commitCanvas(drag.last, { preserveTextEdit: true }); showNotice("对象位置已提交"); setGuides([]); dragRef.current = undefined; } function marqueeSelect(rectangle: CanvasRect, append: boolean) { + finalizeTextHistory(); const controller = controllerForCurrent(); if (!controller) return; setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode)); @@ -796,6 +872,7 @@ export function EditorPage({ projectId }: { projectId: string }) { } function clearSelection() { + finalizeTextHistory(); elementControllerRef.current?.clearSelection(); setSelectedIds([]); setCandidateMenu(undefined); @@ -803,10 +880,7 @@ export function EditorPage({ projectId }: { projectId: string }) { } if (!project || !canvasState) return
正在加载编辑器
; - const renderedCanvasState = textEdit ? { - ...canvasState, - elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element), - } : canvasState; + const renderedCanvasState = withTextDraft(canvasState, textEdit); const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`; const canEdit = saveStatus !== "conflicted"; const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id)); @@ -883,7 +957,6 @@ export function EditorPage({ projectId }: { projectId: string }) { maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`, }}> setCandidateMenu(undefined)} onMarquee={marqueeSelect} onMoveCommit={commitMove} onMovePreview={previewMove} diff --git a/apps/web/src/editor-stage.tsx b/apps/web/src/editor-stage.tsx index 4e61fad..8a70f4a 100644 --- a/apps/web/src/editor-stage.tsx +++ b/apps/web/src/editor-stage.tsx @@ -24,7 +24,6 @@ interface Gesture { const DRAG_THRESHOLD_PX = 4; interface EditorStageProps { - assetId: string | null; canvasState: CanvasState; guides: readonly string[]; fontStatuses: Readonly>; @@ -32,6 +31,7 @@ interface EditorStageProps { onClearSelection: () => void; onCopy: () => void; onDelete: () => void; + onDragStart: () => void; onMarquee: (rectangle: CanvasRect, append: boolean) => void; onMoveCommit: () => void; onMovePreview: (delta: CanvasPoint) => void; @@ -244,6 +244,27 @@ function loadCanvasImage(url: string) { }); } +type CanvasImageLoader = (url: string) => Promise; + +interface SceneResources { + background: HTMLImageElement | undefined; + resourceImages: Readonly>; +} + +function createCachedCanvasImageLoader(): CanvasImageLoader { + const cache = new Map>(); + return (url) => { + const cached = cache.get(url); + if (cached) return cached; + const pending = loadCanvasImage(url).then((image) => { + if (!image) cache.delete(url); + return image; + }); + cache.set(url, pending); + return pending; + }; +} + function resourceUrlsForCanvas(canvasState: CanvasState) { const imageReferences = new Map(); for (const element of canvasState.elements) { @@ -256,11 +277,19 @@ function resourceUrlsForCanvas(canvasState: CanvasState) { return imageReferences; } -async function loadSceneResources(canvasState: CanvasState, projectId: string) { +function sceneResourceKey(canvasState: CanvasState, projectId: string) { const background = canvasState.background.asset_id - ? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`) + ? `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}` + : null; + const resources = [...resourceUrlsForCanvas(canvasState)].toSorted(([left], [right]) => left.localeCompare(right)); + return JSON.stringify({ background, projectId, resources }); +} + +async function loadSceneResources(canvasState: CanvasState, projectId: string, loadImage: CanvasImageLoader = loadCanvasImage): Promise { + const background = canvasState.background.asset_id + ? loadImage(`/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 resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadImage(url)] as const)); const [image, loaded] = await Promise.all([background, resources]); return { background: image, @@ -318,16 +347,29 @@ export function EditorStage(props: EditorStageProps) { const canvasRef = useRef(null); const gestureRef = useRef(undefined); const longPressRef = useRef | undefined>(undefined); + const imageLoaderRef = useRef(undefined); + const [sceneResources, setSceneResources] = useState<{ key: string; resources: SceneResources }>(); const [marquee, setMarquee] = useState(); + const resourceKey = sceneResourceKey(props.canvasState, props.projectId); + + if (!imageLoaderRef.current) imageLoaderRef.current = createCachedCanvasImageLoader(); useEffect(() => { let active = true; + void loadSceneResources(props.canvasState, props.projectId, imageLoaderRef.current).then((resources) => { + if (active) setSceneResources({ key: resourceKey, resources }); + }); + return () => { active = false; }; + }, [props.projectId, resourceKey]); + + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return undefined; - canvas.width = props.canvasState.pixel_width; - canvas.height = props.canvasState.pixel_height; + if (canvas.width !== props.canvasState.pixel_width) canvas.width = props.canvasState.pixel_width; + if (canvas.height !== props.canvasState.pixel_height) canvas.height = props.canvasState.pixel_height; const context = canvas.getContext("2d"); if (!context) return undefined; + if (!sceneResources || sceneResources.key !== resourceKey) return undefined; const render = (image: HTMLImageElement | undefined, resourceImages: Readonly>) => { renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages); context.lineWidth = 4; @@ -349,12 +391,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(); }; - void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => { - if (!active) return; - render(background, resourceImages); - }); - return () => { active = false; }; - }, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]); + render(sceneResources.resources.background, sceneResources.resources.resourceImages); + return undefined; + }, [marquee, props.canvasState, props.fontStatuses, props.guides, props.selectedIds, resourceKey, sceneResources]); useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []); @@ -384,8 +423,12 @@ export function EditorStage(props: EditorStageProps) { return; } const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y); - if (!gesture.moved && clientDistance < DRAG_THRESHOLD_PX) return; - gesture.moved = true; + if (!gesture.moved) { + if (clientDistance < DRAG_THRESHOLD_PX) return; + gesture.moved = true; + gesture.longPressOpened = false; + props.onDragStart(); + } const point = pointFromClient(event.clientX, event.clientY, gesture.bounds); const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y }; if (longPressRef.current) clearTimeout(longPressRef.current); diff --git a/tests/e2e/wp4-02-editor-elements.spec.ts b/tests/e2e/wp4-02-editor-elements.spec.ts index 58c260a..e7c6177 100644 --- a/tests/e2e/wp4-02-editor-elements.spec.ts +++ b/tests/e2e/wp4-02-editor-elements.spec.ts @@ -5,6 +5,8 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; import type { CanvasState } from "@dada/shared-contracts"; +import { P0A_TEXT_TEMPLATES, createTextTemplateElement } from "../../apps/web/src/text-assets.js"; + let vite: ViteDevServer; let webUrl: string; @@ -196,3 +198,89 @@ test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers" writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true }); if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") }); }); + +test("POSTV1-08 keeps the canvas frame stable and previews drag before pointer release", async ({ page }) => { + await page.addInitScript(() => { + const counters = { height: 0, width: 0 }; + Object.defineProperty(window, "__dadaCanvasDimensionWrites", { value: counters }); + for (const key of ["height", "width"] as const) { + const descriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, key); + if (!descriptor?.get || !descriptor.set) throw new Error(`Canvas ${key} descriptor unavailable.`); + Object.defineProperty(HTMLCanvasElement.prototype, key, { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + get: descriptor.get, + set(value: number) { + if (this.classList.contains("editor-canvas")) counters[key] += 1; + descriptor.set!.call(this, value); + }, + }); + } + }); + + const projectId = uuid(530); + const text = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, { + createdAt: "2026-08-03T08:00:00.000Z", + elementId: uuid(630), + }, 0, { position: { x: 0.5, y: 0.5 } }); + const backend = { canvas: canvas([text]), saves: 0, version: 6 }; + await routeEditor(page, projectId, backend); + await page.goto(`${webUrl}/app/projects/${projectId}/editor`); + const stage = page.getByLabel("编辑画布"); + const bounds = await stage.boundingBox(); + if (!bounds) throw new Error("Canvas bounds unavailable."); + const center = { x: bounds.x + bounds.width * 0.5, y: bounds.y + bounds.height * 0.5 }; + await page.mouse.click(center.x, center.y); + await expect(page.getByLabel("文字内容")).toBeVisible(); + await page.getByLabel("文字内容").fill("拖动中的文字"); + await page.getByRole("spinbutton", { name: "有效字号", exact: true }).fill("64"); + await page.getByLabel("文字填充色").fill("#FA5751"); + await expect.poll(() => backend.saves, { timeout: 4_000 }).toBe(1); + expect(backend.canvas.elements[0]).toMatchObject({ + content: "拖动中的文字", + scale: { x: 64 / 48, y: 64 / 48 }, + style_parameters: { fill_color: "#FA5751" }, + }); + const savesBeforeDrag = backend.saves; + + const before = await page.evaluate(() => { + return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites); + }); + await page.mouse.move(center.x, center.y); + await page.mouse.down(); + await page.waitForTimeout(650); + await expect(page.getByRole("menu")).toBeVisible(); + await page.mouse.move(bounds.x + bounds.width * 0.68, center.y); + await expect(page.getByRole("menu")).toBeHidden(); + await expect(page.getByLabel("文字内容")).toBeVisible(); + + const preview = await stage.evaluate((canvas: HTMLCanvasElement) => { + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas context unavailable."); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let count = 0; + let totalX = 0; + for (let y = 0; y < canvas.height; y += 1) { + for (let x = 0; x < canvas.width; x += 1) { + const offset = (y * canvas.width + x) * 4; + if ((pixels[offset] ?? 255) < 20 && (pixels[offset + 1] ?? 0) >= 75 && (pixels[offset + 1] ?? 255) <= 120 && (pixels[offset + 2] ?? 0) >= 180) { + count += 1; + totalX += x; + } + } + } + return { blue_pixel_count: count, blue_x: count > 0 ? totalX / count / canvas.width : 0 }; + }); + const during = await page.evaluate(() => { + return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites); + }); + + expect(during).toEqual(before); + expect(preview.blue_pixel_count).toBeGreaterThan(100); + expect(preview.blue_x).toBeGreaterThan(0.60); + await page.mouse.up(); + await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(savesBeforeDrag); + expect(backend.canvas.elements[0]?.position.x).toBeCloseTo(0.68, 2); + expect(backend.canvas.elements[0]?.content).toBe("拖动中的文字"); + await expect(page.getByLabel("文字内容")).toBeVisible(); +}); diff --git a/tests/e2e/wp4-05-export.spec.ts b/tests/e2e/wp4-05-export.spec.ts index 32b3d77..ef6e006 100644 --- a/tests/e2e/wp4-05-export.spec.ts +++ b/tests/e2e/wp4-05-export.spec.ts @@ -78,7 +78,7 @@ async function routeEditor(page: Page, projectId: string, backend: Backend, opti await page.route("**/api/v1/assets/public/p0a-complex-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 }) => { +test("TDD-WP4-EXP-001 cancel keeps automatically saved text outside the 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 }; @@ -87,27 +87,29 @@ test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and expor 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.getByLabel("文字内容").fill("自动保存的导出文字"); + await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2); 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 expect(dialog).not.toContainText("导出前需要提交当前修改"); + await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0); await dialog.getByRole("button", { name: "取消" }).click(); await expect(dialog).toHaveCount(0); - await expect(page.getByLabel("文字内容")).toHaveValue("尚未提交的导出文字"); - expect(backend.saves).toBe(1); + await expect(page.getByLabel("文字内容")).toHaveValue("自动保存的导出文字"); + expect(backend.saves).toBe(2); + expect(backend.canvas.elements[0]?.content).toBe("自动保存的导出文字"); 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); + await expect(page.getByLabel("文字内容")).toHaveValue("春日计划"); 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 }); + evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "自动保存的导出文字", first_undo_restored_initial_text: true, latest_exports_changed: false }); }); -test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes", async ({ page }) => { +test("TDD-WP4-EXP-001 exports automatically saved text 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 }; @@ -117,6 +119,7 @@ test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes" await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click(); await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1); await page.getByLabel("文字内容").fill("确认后进入导出"); + await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2); await page.getByRole("button", { name: "导出", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "导出成品" }); if (process.env.DADA_EVIDENCE_DIR_EXPORT) { @@ -124,14 +127,14 @@ test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes" mkdirSync(dirname(screenshot), { recursive: true }); await page.screenshot({ fullPage: true, path: screenshot }); } - await dialog.getByRole("checkbox", { name: "将应用当前修改并导出" }).check(); + await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0); 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.saves).toBe(2); expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出"); expect(backend.latestBodies).toHaveLength(1); const downloaded = readFileSync(downloadPath);