import { expect, test, type Page } from "@playwright/test"; import { createServer, type ViteDevServer } from "vite"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; 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; 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-editor-elements-00000000000000000000000000000000000", expires_at: "2026-09-03T08:00:00.000Z", user: { creator_name: "Canvas User", role: "user", social_id: "@canvas_user", status: "active", user_id: "00000000-0000-4000-8000-000000000501" }, }; const stickerRoot = process.env.DADA_STATIC_STICKER_ROOT ?? join(homedir(), "Desktop", "贴纸素材"); const originalStickerFixtures: Readonly> = { STK001: join(stickerRoot, "sticker_part1", "01af6384c2a962d17f55736f9895b505.png"), STK002: join(stickerRoot, "sticker_part2", "011db0fb6ac4184e4a708374003bce66.png"), }; function uuid(index: number) { return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; } function sticker(index: number, position: { x: number; y: number }, zIndex = index - 1): CanvasState["elements"][number] { return { created_at: "2026-08-03T08:00:00.000Z", element_id: uuid(600 + index), opacity: 1, position, resource_version: "fixture-v1", rotation: 0, scale: { x: 1, y: 1 }, style_parameters: { flip_horizontal: false }, template_or_asset_id: `STK${String(index).padStart(3, "0")}`, type: "static_sticker", z_index: zIndex, }; } function canvas(elements: CanvasState["elements"]): CanvasState { return { background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null }, elements, pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1, }; } function writeEvidence(caseId: string, name: string, value: unknown) { const root = process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS; 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, projectId: string, backend: { canvas: CanvasState; saves: number; version: number }) { if (!Object.values(originalStickerFixtures).every(existsSync)) throw new Error("Archived ordinary sticker fixture is unavailable."); 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({ canvas_state: backend.canvas, created_at: "2026-08-03T08:00:00.000Z", current_image_id: null, draft_prompt: "贴纸测试", generations: [], images: [], name: "贴纸画布", pixel_height: 1440, pixel_width: 1080, project_id: projectId, ratio: "3:4", save_status: "saved", state_version: backend.version, status: "active", successful_image_count: 1, updated_at: "2026-08-03T08:00:00.000Z", }), contentType: "application/json", status: 200, })); 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", status: 200 }); }); await page.route("**/api/v1/assets/public/fixture-v1/*", (route) => { const assetId = decodeURIComponent(route.request().url().split("/").at(-1)!); const source = originalStickerFixtures[assetId]; if (!source) return route.fulfill({ status: 404 }); return route.fulfill({ body: readFileSync(source), contentType: "image/png", status: 200 }); }); } test("TDD-WP4-CAN-001 keeps fifty elements editable and blocks the fifty-first", async ({ page }) => { const projectId = uuid(510); const elements = Array.from({ length: 49 }, (_, index) => sticker(index + 1, { x: 0.15 + (index % 7) * 0.1, y: 0.15 + (Math.floor(index / 7) % 7) * 0.1 })); const backend = { canvas: canvas(elements), saves: 0, version: 3 }; 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: "添加贴纸 STK001" }).click(); await expect(page.getByText("对象 50 / 50")).toBeVisible(); await expect(page.getByRole("button", { name: "添加贴纸 STK001" })).toBeDisabled(); await expect(page.getByText("画布最多 50 个元素,请先删除现有元素。").first()).toBeVisible(); await expect.poll(() => backend.saves, { timeout: 4_000 }).toBe(1); const beforeBlockedAction = structuredClone(backend.canvas); await page.getByRole("button", { name: "复制", exact: true }).last().click(); await expect(page.getByText("画布最多 50 个元素,请先删除现有元素。").first()).toBeVisible(); await page.waitForTimeout(1_200); expect(backend.canvas).toEqual(beforeBlockedAction); const stage = page.getByLabel("编辑画布"); const bounds = await stage.boundingBox(); if (!bounds) throw new Error("Canvas bounds unavailable."); const started = await page.evaluate(() => performance.now()); const multiMode = page.getByRole("button", { name: "多选模式" }); await multiMode.click(); await expect(multiMode).toHaveAttribute("aria-pressed", "true"); await multiMode.click(); await stage.press("Escape"); await page.mouse.move(bounds.x + bounds.width * 0.02, bounds.y + bounds.height * 0.02); await page.mouse.down(); await page.mouse.move(bounds.x + bounds.width * 0.32, bounds.y + bounds.height * 0.22, { steps: 3 }); await page.mouse.up(); await expect(page.getByRole("heading", { name: /已选 [2-9][0-9]* 个对象/ })).toBeVisible(); await page.getByRole("button", { name: "向右移动" }).click(); const elapsed = await page.evaluate((start) => performance.now() - start, started); await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(1); expect(backend.canvas.elements).toHaveLength(50); expect(backend.canvas.elements[0]?.position.x).toBeCloseTo(0.16); expect(page.getByText("图层面板", { exact: true })).toHaveCount(0); expect(page.getByText("隐藏", { exact: true })).toHaveCount(0); expect(page.getByText("锁定", { exact: true })).toHaveCount(0); expect(page.getByText("编组", { exact: true })).toHaveCount(0); writeEvidence("TDD-WP4-CAN-001-fifty-elements", "canvas-state.json", backend.canvas); writeEvidence("TDD-WP4-CAN-001-fifty-elements", "db-diff.json", { element_count: 50, fifty_first_state_delta: 0, saves: backend.saves }); writeEvidence("TDD-WP4-CAN-001-fifty-elements", "performance.json", { interaction_elapsed_ms: elapsed, sustained_unresponsive: false }); if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-CAN-001-fifty-elements", "fifty-elements.png") }); }); test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers", async ({ page }) => { const projectId = uuid(520); const backend = { canvas: canvas([sticker(1, { x: 0.5, y: 0.5 }, 0), sticker(2, { x: 0.5, y: 0.5 }, 1)]), saves: 0, version: 5 }; 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 / 2, y: bounds.y + bounds.height / 2 }; await page.mouse.move(center.x, center.y); await page.mouse.down(); await page.mouse.up(); await expect(page.getByText("STK002", { exact: true })).toBeVisible(); await page.mouse.down(); await page.mouse.up(); await expect(page.getByText("STK001", { exact: true })).toBeVisible(); await stage.press("Escape"); await page.mouse.move(center.x, center.y); await page.mouse.down(); await page.waitForTimeout(650); await expect(page.getByRole("menu")).toBeVisible(); await expect(page.getByRole("menuitem")).toHaveCount(2); await page.mouse.up(); await page.getByRole("menuitem", { name: /STK001/ }).click(); await page.getByRole("button", { name: "向右移动" }).click(); await page.getByRole("button", { name: "放大" }).click(); await page.getByRole("button", { name: "顺时针" }).click(); await page.getByRole("button", { name: "水平翻转" }).click(); const opacity = page.locator(".editor-inspector input[type=range]"); await opacity.dispatchEvent("pointerdown"); await opacity.fill("80"); await expect(page.locator(".editor-inspector output")).toHaveText("80%"); await opacity.fill("60"); await expect(page.locator(".editor-inspector output")).toHaveText("60%"); await opacity.fill("35"); await expect(page.locator(".editor-inspector output")).toHaveText("35%"); await opacity.dispatchEvent("pointerup"); await page.getByRole("button", { name: "撤销" }).click(); await expect(page.locator(".editor-inspector output")).toHaveText("100%"); await page.getByRole("button", { name: "重做" }).click(); await expect(page.locator(".editor-inspector output")).toHaveText("35%"); await page.getByRole("button", { name: "置顶" }).click(); await page.getByRole("button", { name: "复制", exact: true }).last().click(); await expect(page.getByText("对象 3 / 50")).toBeVisible(); await page.getByRole("button", { name: "撤销" }).click(); await expect(page.getByText("对象 2 / 50")).toBeVisible(); await page.getByRole("button", { name: "重做" }).click(); await expect(page.getByText("对象 3 / 50")).toBeVisible(); await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0); const transformed = backend.canvas.elements.find((element) => element.element_id === uuid(601)); expect(transformed).toMatchObject({ opacity: 0.35, position: { x: 0.51, y: 0.5 }, rotation: 15, scale: { x: 1.1, y: 1.1 }, style_parameters: { flip_horizontal: true }, template_or_asset_id: "STK001", }); await page.reload(); await expect(page.getByText("对象 3 / 50")).toBeVisible(); writeEvidence("TDD-WP4-STK-001-transform-sticker", "canvas-state.json", backend.canvas); writeEvidence("TDD-WP4-STK-001-transform-sticker", "db-diff.json", { reopened_element_count: backend.canvas.elements.length, saves: backend.saves, state_version: backend.version }); 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(); });