import { expect, test } from "@playwright/test"; import { mockInvestigationPresentation } from "./support/mock-investigation-presentation"; test.use({ reducedMotion: "reduce", viewport: { width: 1600, height: 1000 } }); for (const target of ["manager", "child"] as const) { test(`resizing ${target} reflows charts at their actual dimensions without stretching text`, async ({ page }, testInfo) => { test.setTimeout(90_000); await mockInvestigationPresentation(page, []); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: "切换地图 Dev Panel" }).click(); const tools = page.getByTestId("investigation-dev-tools"); await tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true }).click(); await page.getByRole("option", { name: "复杂图表:多测点与长时序", exact: true }).click(); await tools.getByRole("button", { name: "执行本地预览", exact: true }).click(); await expect(tools.getByRole("status")).toContainText("本地预览完成", { timeout: 20_000 }); const panel = page.getByLabel("本地预览展示", { exact: true }); await panel.getByRole("button", { name: "打开图表管理", exact: true }).click(); const manager = page.getByRole("dialog", { name: "图表管理", exact: true }); if (target === "child") await manager.getByRole("button", { name: "放大查看 合成全天压力对比", exact: true }).click(); const popup = target === "manager" ? manager : page.getByRole("dialog", { name: "合成全天压力对比", exact: true }); await expect(popup.locator(".echarts-for-react svg").first()).toBeVisible(); await popup.locator(".floating-window-heading").hover(); await page.evaluate(() => document.fonts.ready); const instances = await popup.locator(".echarts-for-react").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("_echarts_instance_"))); const drawingSize = await popup.locator(".echarts-for-react").first().evaluate((node) => ({ width: node.clientWidth, height: node.clientHeight })); const font = await popup.locator(".echarts-for-react svg text").first().evaluate((node) => ({ size: getComputedStyle(node).fontSize, height: node.getBoundingClientRect().height })); const before = (await popup.boundingBox())!; const handle = (await popup.locator(".floating-window-resize").boundingBox())!; const session = await page.context().newCDPSession(page); await session.send("Emulation.setCPUThrottlingRate", { rate: 4 }); await session.send("Performance.enable"); await popup.evaluate(async (node) => { await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); Object.assign(window, { chartResizeMutations: 0 }); const observer = new MutationObserver((records) => { (window as unknown as { chartResizeMutations: number }).chartResizeMutations += records.length; }); node.querySelectorAll(".echarts-for-react svg").forEach((svg) => observer.observe(svg, { subtree: true, attributes: true, childList: true })); }); const first = await session.send("Performance.getMetrics"); const started = Date.now(); await page.mouse.move(handle.x + handle.width - 3, handle.y + handle.height - 3); await page.mouse.down(); await page.mouse.move(handle.x + handle.width - 3 + 128, handle.y + handle.height - 3 + 64, { steps: 32 }); // While the pointer is still held, layout and the chart drawing must adapt together. await expect.poll(() => popup.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientWidth - Number(node.querySelector("svg")?.getAttribute("width"))))).toBeLessThan(1); await expect.poll(() => popup.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientHeight - Number(node.querySelector("svg")?.getAttribute("height"))))).toBeLessThan(1); const during = await page.evaluate(() => (window as unknown as { chartResizeMutations: number }).chartResizeMutations); const preview = (await popup.boundingBox())!; await expect(popup.locator("[data-floating-window-document]")).toHaveCSS("transform", "none"); expect(await popup.locator("[data-floating-window-document]").evaluate((node) => ({ width: (node as HTMLElement).style.width, height: (node as HTMLElement).style.height }))).toEqual({ width: "", height: "" }); const resizedFont = await popup.locator(".echarts-for-react svg text").first().evaluate((node) => ({ size: getComputedStyle(node).fontSize, height: node.getBoundingClientRect().height })); expect(resizedFont.size).toBe(font.size); expect(Math.abs(resizedFont.height - font.height)).toBeLessThan(1); const actualDrawing = await popup.locator(".echarts-for-react").first().evaluate((node) => ({ width: node.clientWidth, height: node.clientHeight })); expect(actualDrawing.width).toBeGreaterThan(drawingSize.width); expect(actualDrawing.height).toBeGreaterThanOrEqual(drawingSize.height); await page.mouse.up(); const last = await session.send("Performance.getMetrics"); const metric = (name: string) => (last.metrics.find((item) => item.name === name)?.value ?? 0) - (first.metrics.find((item) => item.name === name)?.value ?? 0); const summary = { target, svgMutationsDuringResize: during, elapsedMs: Date.now() - started, scriptMs: metric("ScriptDuration") * 1000, layoutMs: metric("LayoutDuration") * 1000, taskMs: metric("TaskDuration") * 1000 }; console.info("Chart resize profile", JSON.stringify(summary)); await testInfo.attach("resize-profile", { body: JSON.stringify(summary, null, 2), contentType: "application/json" }); expect(Math.round(preview.width - before.width)).toBe(128); expect(Math.round(preview.height - before.height)).toBe(64); expect(during).toBeGreaterThan(0); // Bound the expensive work on the same 4x CPU fixture used to reproduce the lag. expect(summary.elapsedMs).toBeLessThan(12_000); expect(summary.scriptMs).toBeLessThan(6_000); expect(await popup.locator(".echarts-for-react").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("_echarts_instance_")))).toEqual(instances); await expect.poll(() => popup.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientWidth - Number(node.querySelector("svg")?.getAttribute("width"))))).toBeLessThan(1); const layout = await popup.evaluate((node) => ({ plots: Array.from(node.querySelectorAll(".echarts-for-react")).filter((plot) => plot.getBoundingClientRect().height > 0).map((plot) => plot.clientHeight), legends: Array.from(node.querySelectorAll('[data-chart-group-tile="true"]')).map((card) => { const legend = card.querySelector('[aria-label="图表图例"]')!; const first = legend.querySelector("button")!; return { height: legend.clientHeight, firstRowHeight: first.getBoundingClientRect().height, bottomOverflow: legend.getBoundingClientRect().bottom - card.getBoundingClientRect().bottom }; }) })); for (const height of layout.plots) expect(height).toBeGreaterThanOrEqual(100); for (const legend of layout.legends) { expect(legend.height).toBeGreaterThanOrEqual(legend.firstRowHeight - 1); expect(legend.bottomOverflow).toBeLessThanOrEqual(1); } await testInfo.attach("resize-layout", { body: JSON.stringify(layout, null, 2), contentType: "application/json" }); await session.send("Emulation.setCPUThrottlingRate", { rate: 1 }); const chartSurface = target === "manager" ? manager.getByRole("group", { name: "合成全天压力对比", exact: true }) : popup; const selectedObject = chartSurface.getByRole("button", { name: /^切换对象 / }).nth(1); await selectedObject.click(); const hiddenSeries = chartSurface.getByLabel("显示的数据序列").getByRole("button").last(); await hiddenSeries.click(); await expect(selectedObject).toHaveAttribute("aria-pressed", "true"); await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false"); const selectedInstance = await chartSurface.locator(".echarts-for-react").first().getAttribute("_echarts_instance_"); const beforeCancel = (await popup.boundingBox())!; const cancelHandle = popup.locator(".floating-window-resize"); const cancelBox = (await cancelHandle.boundingBox())!; await cancelHandle.evaluate((node) => node.addEventListener("pointerdown", (event) => { (node as HTMLElement).dataset.testPointerId = String((event as PointerEvent).pointerId); }, { once: true })); await page.mouse.move(cancelBox.x + cancelBox.width - 3, cancelBox.y + cancelBox.height - 3); await page.mouse.down(); await page.mouse.move(cancelBox.x + cancelBox.width - 3 + 48, cancelBox.y + cancelBox.height - 3 + 32, { steps: 4 }); await expect.poll(async () => Math.round((await popup.boundingBox())!.width - beforeCancel.width)).toBe(48); await cancelHandle.evaluate((node) => node.dispatchEvent(new PointerEvent("pointercancel", { pointerId: Number((node as HTMLElement).dataset.testPointerId) }))); await page.mouse.up(); await expect.poll(() => popup.boundingBox()).toEqual(beforeCancel); await expect(popup.locator("[data-floating-window-document]")).toHaveCSS("transform", "none"); await expect(selectedObject).toHaveAttribute("aria-pressed", "true"); await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false"); const boundaryBox = (await cancelHandle.boundingBox())!; await page.mouse.move(boundaryBox.x + boundaryBox.width - 3, boundaryBox.y + boundaryBox.height - 3); await page.mouse.down(); await page.mouse.move(1599, 999, { steps: 8 }); await page.mouse.up(); const final = (await popup.boundingBox())!; expect(Math.round(final.x + final.width)).toBe(1592); expect(Math.round(final.y + final.height)).toBe(992); await expect(selectedObject).toHaveAttribute("aria-pressed", "true"); await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false"); await expect(chartSurface.locator(".echarts-for-react").first()).toHaveAttribute("_echarts_instance_", selectedInstance!); await expect.poll(() => chartSurface.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientWidth - Number(node.querySelector("svg")?.getAttribute("width"))))).toBeLessThan(1); await testInfo.attach(`resize-${target}`, { body: await popup.screenshot({ path: testInfo.outputPath(`resize-${target}.png`) }), contentType: "image/png" }); }); }