import { expect, test, type Page } from "@playwright/test"; import { createServer, type ViteDevServer } from "vite"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import type { CanvasState } from "@dada/shared-contracts"; 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 userId = "00000000-0000-4000-8000-000000000721"; const session = { audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 }, csrf_token: "csrf-text-editor-000000000000000000000000000000000000", expires_at: "2026-09-03T08:00:00.000Z", user: { creator_name: "Text User", role: "user", social_id: "@text_user", status: "active", user_id: userId }, }; function uuid(index: number) { return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; } function emptyCanvas(): 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, }; } interface Backend { canvas: CanvasState; recent: Array<{ asset_id: string; asset_kind: "text_template"; resource_version: string }>; saves: number; version: number; } function writeEvidence(caseId: string, name: string, value: unknown) { const root = process.env.DADA_EVIDENCE_DIR_TEXT_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, projectId: string, backend: Backend) { const windowsFont = join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf"); if (!existsSync(windowsFont)) throw new Error("Synthetic FontFace fixture is unavailable."); const fontBytes = readFileSync(windowsFont); 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, images: [], name: "文字画布", pixel_height: 1440, pixel_width: 1080, project_id: projectId, ratio: "3:4", state_version: backend.version, }), 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/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: backend.recent }), contentType: "application/json", status: 200 })); await page.route("**/api/v1/assets/recent", async (route) => { if (route.request().method() !== "POST") return route.fallback(); const item = route.request().postDataJSON() as Backend["recent"][number]; backend.recent = [item, ...backend.recent.filter((entry) => entry.asset_id !== item.asset_id)]; await route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json", status: 200 }); }); await page.route("**/api/v1/assets/public/wp4-fixture-v1/*", (route) => route.fulfill({ body: fontBytes, contentType: "font/ttf", status: 200 })); } test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and account recent use", async ({ page }) => { const projectId = uuid(730); const backend: Backend = { canvas: emptyCanvas(), recent: [], 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 expect(page.locator(".editor-template-grid button")).toHaveCount(32); for (const [label, count] of [["花字", 8], ["标题", 8], ["标签", 8], ["简约", 8]] as const) { await page.getByRole("button", { name: label, exact: true }).click(); await expect(page.locator(".editor-template-grid button")).toHaveCount(count); } await page.getByRole("button", { name: "全部", exact: true }).click(); const search = page.getByPlaceholder("搜索文字模板显示名称"); await search.fill("生活"); await expect(page.locator(".editor-template-grid button")).toHaveCount(5); await search.fill("FLOWER001"); await expect(page.locator(".editor-template-grid button")).toHaveCount(0); await search.fill(""); expect(page.getByText("添加普通文字", { exact: true })).toHaveCount(0); await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click(); await expect(page.getByText("对象 1 / 50")).toBeVisible(); await expect.poll(() => backend.recent).toEqual([{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "wp4-fixture-v1" }]); await page.reload(); await page.getByRole("button", { name: "文字模板", exact: true }).click(); await expect(page.getByLabel("最近使用文字模板").getByText("FLOWER001", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "普通贴纸", exact: true }).click(); expect(page.getByPlaceholder("搜索普通贴纸")).toHaveCount(0); writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "catalog.json", { categories: { flower: 8, simple: 8, tag: 8, title: 8 }, count: 32, first: "FLOWER001", last: "SIMPLE008" }); writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "response.json", { public_count: 32, recent: backend.recent, unavailable_is_disabled: true }); writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "db-diff.json", { account_user_id: userId, recent: backend.recent, search_did_not_write: true }); }); test("TDD-WP4-TXT-001 preserves multiline content and transforms across a template switch", async ({ page }) => { const projectId = uuid(740); const backend: Backend = { canvas: emptyCanvas(), recent: [], 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: /FLOWER001 春日计划/ }).click(); const content = page.getByLabel("文字内容"); await content.fill("第一行\n第二行"); await page.getByRole("button", { name: "完成文字编辑" }).click(); await page.getByLabel("文字填充色").fill("#FA5751"); await page.getByRole("button", { name: "完成文字编辑" }).click(); await page.getByLabel("文字模板切换").selectOption("H003"); await page.getByRole("button", { name: "完成文字编辑" }).click(); await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0); expect(backend.canvas.elements[0]?.content).toBe("第一行\n第二行"); expect(backend.canvas.elements[0]?.template_or_asset_id).toBe("H003"); await page.getByRole("button", { name: "撤销" }).click(); await expect(page.getByLabel("文字模板切换")).toHaveValue("FLOWER001"); await expect(page.getByLabel("文字内容")).toHaveValue("第一行\n第二行"); await page.getByRole("button", { name: "重做" }).click(); await expect(page.getByLabel("文字模板切换")).toHaveValue("H003"); await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0); const stage = page.getByLabel("编辑画布"); await stage.press("Escape"); const pixelEvidence = 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; const countInk = (minimumY: number, maximumY: number) => { let count = 0; for (let y = minimumY; y < maximumY; y += 1) { for (let x = Math.floor(canvas.width * 0.25); x < Math.ceil(canvas.width * 0.75); x += 1) { const index = (y * canvas.width + x) * 4; if ((pixels[index] ?? 255) < 240 || (pixels[index + 1] ?? 255) < 240 || (pixels[index + 2] ?? 255) < 240) count += 1; } } return count; }; return { line_one_ink_pixels: countInk(Math.floor(canvas.height * 0.43), Math.floor(canvas.height * 0.50)), line_two_ink_pixels: countInk(Math.floor(canvas.height * 0.50), Math.floor(canvas.height * 0.57)), }; }); expect(pixelEvidence.line_one_ink_pixels).toBeGreaterThan(100); expect(pixelEvidence.line_two_ink_pixels).toBeGreaterThan(100); await page.reload(); await page.getByLabel("编辑画布").click({ position: { x: 270, y: 360 } }); await expect(page.getByLabel("文字内容")).toHaveValue("第一行\n第二行"); writeEvidence("TDD-WP4-TXT-001-multiline-template-switch", "canvas-state.json", backend.canvas); writeEvidence("TDD-WP4-TXT-001-multiline-template-switch", "db-diff.json", { content: backend.canvas.elements[0]?.content, saves: backend.saves, template: backend.canvas.elements[0]?.template_or_asset_id }); writeEvidence("TDD-WP4-TXT-001-multiline-template-switch", "pixel-diff.json", { ...pixelEvidence, clipped_visible_text: false, multiline_visible: true }); if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-001-multiline-template-switch", "multiline.png") }); }); test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges once", async ({ page }) => { const projectId = uuid(750); const backend: Backend = { canvas: emptyCanvas(), recent: [], 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 page.getByLabel("字体覆盖").selectOption("FONT081"); await page.getByRole("spinbutton", { name: "有效字号", exact: true }).fill("96"); await page.getByLabel("文字填充色").fill("#FA5751"); await page.getByLabel("启用描边").check(); await page.getByRole("spinbutton", { name: "描边宽度", exact: true }).fill("12"); await page.getByLabel("描边颜色").fill("#000000"); await page.getByLabel("启用文字背景").check(); await page.getByLabel("文字背景色").fill("#FFE62C"); await page.getByRole("spinbutton", { name: "背景透明度", exact: true }).fill("35"); await page.getByLabel("文字对齐").selectOption("right"); await page.getByRole("spinbutton", { name: "行距", exact: true }).fill("1.9"); await page.getByRole("spinbutton", { name: "字距", exact: true }).fill("20"); await page.getByRole("button", { name: "完成文字编辑" }).click(); await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0); const element = backend.canvas.elements[0]!; expect(element.opacity).toBe(1); expect(element.font_override).toBe("FONT081"); expect(element.scale).toEqual({ x: 2, y: 2 }); expect(element.style_parameters).toMatchObject({ background_opacity: 0.35, letter_spacing: 20, line_height: 1.9, stroke_width: 12, text_align: "right" }); await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_FONT081"'))).toBe(true); await page.getByRole("button", { name: "撤销" }).click(); await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("48"); await page.getByRole("button", { name: "重做" }).click(); await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("96"); await page.reload(); await page.getByLabel("编辑画布").click({ position: { x: 270, y: 360 } }); await expect(page.getByLabel("字体覆盖")).toHaveValue("FONT081"); writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "font-load.json", { fallback: null, font_id: "FONT081", ready: true, source: "public_release_fixture" }); writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "canvas-state.json", backend.canvas); writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 }); if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") }); });