import { expect, test, type Page } from "@playwright/test"; import { createServer, type ViteDevServer } from "vite"; import { mkdirSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; 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 projectId = "00000000-0000-4000-8000-000000000451"; const currentImageId = "00000000-0000-4000-8000-000000000452"; const alternateImageId = "00000000-0000-4000-8000-000000000453"; const elementId = "00000000-0000-4000-8000-000000000454"; const session = { audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 }, csrf_token: "csrf-editor-fixture-000000000000000000000000000000000000", expires_at: "2026-09-02T08:00:00.000Z", user: { creator_name: "Editor User", role: "user", social_id: "@editor_user", status: "active", user_id: "00000000-0000-4000-8000-000000000455" }, }; const initialCanvasState = { background: { adjustments: { brightness: 12, contrast: -8, crop: null, filter: "sepia", fit: "fit", saturation: 20, sharpness: 30, temperature: -10 }, asset_id: currentImageId }, elements: [{ colors: ["#111111", "#222222", "#333333", "#444444", "#555555"], created_at: "2026-08-02T08:00:00.000Z", element_id: elementId, opacity: 1, position: { x: 0.4, y: 0.3 }, resource_version: "v1", rotation: 12, scale: { x: 1.25, y: 0.8 }, template_or_asset_id: "palette-card", type: "color_card", z_index: 0 }], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1, }; function projectPayload() { return { canvas_state: initialCanvasState, created_at: "2026-08-02T08:00:00.000Z", current_image_id: currentImageId, draft_prompt: "城市工作室", generations: [], images: [ { created_at: "2026-08-02T08:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000456", image_id: currentImageId }, { created_at: "2026-08-02T08:05:00.000Z", generation_id: "00000000-0000-4000-8000-000000000457", image_id: alternateImageId }, ], name: "城市工作室", pixel_height: 1440, pixel_width: 1080, project_id: projectId, ratio: "3:4", save_status: "saved", state_version: 7, status: "active", successful_image_count: 2, updated_at: "2026-08-02T08:05:00.000Z", }; } function writeEvidence(caseId: string, name: string, value: unknown) { const root = process.env.DADA_EVIDENCE_DIR_EDITOR; if (!root) return; const directory = resolve(root, caseId); mkdirSync(directory, { recursive: true }); writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`); } async function routeEditor(page: Page) { await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 })); await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ body: JSON.stringify(projectPayload()), contentType: "application/json", status: 200 })); await page.route(`**/api/v1/private-assets/projects/${projectId}/images/**`, (route) => route.fulfill({ body: Buffer.from("not-an-image"), contentType: "image/png", status: 200 })); } test("TDD-WP4-BG-001 preserves overlays while switching the background", async ({ page }) => { await routeEditor(page); const saves: Array> = []; let version = 7; await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => { const payload = route.request().postDataJSON() as Record; saves.push(payload); version += 1; await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: version }), contentType: "application/json", status: 200 }); }); await page.goto(`${webUrl}/app/projects/${projectId}/editor`); await expect(page.getByRole("heading", { name: "底图参数" })).toBeVisible(); await expect(page.getByText("城市工作室")).toBeVisible(); await expect(page.getByRole("button", { name: /生成图/ }).last()).toBeVisible(); await page.getByRole("button", { name: /生成图/ }).last().click(); const dialog = page.getByRole("dialog", { name: "更换底图" }); await expect(dialog.getByText("覆盖元素会保留,底图编辑参数将重置。", { exact: true })).toBeVisible(); writeEvidence("TDD-WP4-BG-001-switch-background", "canvas-before.json", initialCanvasState); await dialog.getByRole("button", { name: "确认更换" }).click(); await expect(page.getByText("已更换底图,覆盖元素保留,底图处理已重置")).toBeVisible(); await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(0); const saved = saves.at(-1) as { canvas_state: typeof initialCanvasState }; expect(saved.canvas_state.background.asset_id).toBe(alternateImageId); expect(saved.canvas_state.background.adjustments).toEqual({ brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }); expect(saved.canvas_state.elements[0]?.position).toEqual(initialCanvasState.elements[0]?.position); expect(saved.canvas_state.elements[0]?.scale).toEqual(initialCanvasState.elements[0]?.scale); expect(saved.canvas_state.elements[0]?.rotation).toBe(initialCanvasState.elements[0]?.rotation); expect(saved.canvas_state.elements[0]?.colors).not.toEqual(initialCanvasState.elements[0]?.colors); await page.screenshot({ fullPage: true, path: process.env.DADA_EVIDENCE_DIR_EDITOR ? resolve(process.env.DADA_EVIDENCE_DIR_EDITOR, "TDD-WP4-BG-001-switch-background", "switch-background.png") : undefined }); writeEvidence("TDD-WP4-BG-001-switch-background", "canvas-after.json", saved.canvas_state); writeEvidence("TDD-WP4-BG-001-switch-background", "db-diff.json", { state_version_before: 7, state_version_after: version, saves: saves.length }); writeEvidence("TDD-WP4-BG-001-switch-background", "pixel-diff.json", { overlay_position_preserved: true, background_processing_reset: true, palette_refreshed: true }); }); test("TDD-WP4-BG-002 commits, reopens, undoes, and resets background processing", async ({ page }) => { await routeEditor(page); const saves: Array> = []; let version = 7; await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => { saves.push(route.request().postDataJSON() as Record); version += 1; await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: version }), contentType: "application/json", status: 200 }); }); await page.goto(`${webUrl}/app/projects/${projectId}/editor`); const ranges = page.locator("input[type=range]"); await ranges.nth(0).fill("25"); await ranges.nth(1).fill("15"); await ranges.nth(2).fill("-20"); await ranges.nth(3).fill("30"); await ranges.nth(4).fill("60"); await page.getByLabel("滤镜").selectOption("grayscale"); await page.getByLabel("适配方式").selectOption("crop"); await expect(page.getByRole("button", { name: "应用调整" })).toBeEnabled(); await page.getByRole("button", { name: "应用调整" }).click(); await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(0); const committed = saves.at(-1) as { canvas_state: typeof initialCanvasState }; expect(committed.canvas_state.background.adjustments).toMatchObject({ brightness: 25, contrast: 15, saturation: -20, temperature: 30, sharpness: 60, filter: "grayscale", fit: "crop" }); await expect(page.getByText("已保存", { exact: true })).toBeVisible({ timeout: 4_000 }); await page.getByRole("button", { name: "撤销" }).click(); await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(1); const undone = saves.at(-1) as { canvas_state: typeof initialCanvasState }; expect(undone.canvas_state.background.adjustments).toEqual(initialCanvasState.background.adjustments); await page.getByRole("button", { name: "恢复原图" }).click(); await expect(page.getByRole("button", { name: "应用调整" })).toBeEnabled(); await page.getByRole("button", { name: "应用调整" }).click(); await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(2); const reset = saves.at(-1) as { canvas_state: typeof initialCanvasState }; expect(reset.canvas_state.background.adjustments).toEqual({ brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }); expect(reset.canvas_state.background.asset_id).toBe(currentImageId); expect(reset.canvas_state.elements[0]?.position).toEqual(initialCanvasState.elements[0]?.position); writeEvidence("TDD-WP4-BG-002-processing-controls", "canvas-state.json", reset.canvas_state); for (const [key, value] of Object.entries(reset.canvas_state.background.adjustments)) writeEvidence(`TDD-WP4-BG-002-processing-controls/${key}`, "canvas-state.json", { [key]: value }); writeEvidence("TDD-WP4-BG-002-processing-controls", "db-diff.json", { state_version_before: 7, state_version_after: version, saves: saves.length }); writeEvidence("TDD-WP4-BG-002-processing-controls", "pixel-diff.json", { preview_commit_undo_reset: true, export_source_canvas_state_stable: true }); await page.screenshot({ fullPage: true, path: process.env.DADA_EVIDENCE_DIR_EDITOR ? resolve(process.env.DADA_EVIDENCE_DIR_EDITOR, "TDD-WP4-BG-002-processing-controls", "processing-controls.png") : undefined }); });