Files
next-tjwater-frontend/tests/browser/investigation-chart-layout.e2e.ts
T
jiang 0ae0a41e61
Generic Container CI/CD / test-build-publish (push) Successful in 2m7s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m8s
feat(charts): manage investigation charts in a scrolling workspace
2026-11-10 17:56:23 +08:00

481 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Locator, type Page } from "@playwright/test";
import { basename, resolve } from "node:path";
import { writeFile } from "node:fs/promises";
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
const times = Array.from({ length: 13 }, (_, index) => `${String(5 + Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
const allDayTimes = Array.from({ length: 96 }, (_, index) => `${String(Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
const chart = (id: string, value = 28) => ({ action: "open_chart", chart_id: id, evidence_refs: ["synthetic:pressure"],
chart: { title: `合成监测 ${id}`, chart_type: "line", x_data: times, series: [
{ name: "压力(m)", data: times.map((_, index) => index === 12 ? value : 32 - index / 4) },
{ name: "参考(m)", data: times.map((_, index) => 31 - index / 8) }
] } });
const comparison = (id: string, flow = false) => {
const labels = flow ? allDayTimes : times;
return { action: "open_chart", chart_id: `comparison_${id}`, evidence_refs: [`synthetic:${id}`],
chart: { title: `${id} ${flow ? "管流" : "压力"}七日同时刻对照(${flow ? "m³/h" : "m"})`, chart_type: "time_comparison", x_axis_name: "时间", y_axis_name: flow ? "流量(m³/h)" : "压力(m)", x_data: labels,
series: [{ name: `${id} 当前 2026-07-16`, type: "line", data: labels.map((_, index) => flow ? 1237 + Math.sin(index / 8) * 245 : 32 - index / 4),
sample_times: labels.map((time) => `2026-07-16T${time}:00+08:00`) },
...Array.from({ length: 7 }, (_, day) => ({ name: `${id} 2026-07-${String(9 + day).padStart(2, "0")}`, type: "scatter",
data: labels.map((_, index) => index < labels.length - 3 ? null : flow ? 1360 + day * 21 : 30 + day / 2),
sample_times: labels.map((time, index) => index < labels.length - 3 ? null : `2026-07-${String(9 + day).padStart(2, "0")}T${time}:00+08:00`) }))]
} };
};
const card = (manager: Locator, id: string) => manager.locator(`[data-managed-chart][data-chart-title="合成监测 ${id}"]`);
const slots = (manager: Locator) => manager.locator("[data-chart-slot]:visible");
const positions = (manager: Locator) => manager.locator("[data-managed-chart]").evaluateAll((nodes) => Object.fromEntries(nodes.map((node) => [
node.getAttribute("data-chart-title"), Number(node.getAttribute("data-slot-index"))
])));
async function expectCompleteLegendRow(article: Locator) {
const legend = article.getByLabel("图表图例", { exact: true });
const row = article.getByLabel("显示的数据序列", { exact: true }).getByRole("button").first();
const rowHeight = (await row.boundingBox())!.height;
const box = await legend.evaluate((node) => ({
height: node.clientHeight, padding: Number.parseFloat(getComputedStyle(node).paddingTop)
}));
expect(box.height, "a full legend button must fit without being clipped by its scrolling container").toBeGreaterThanOrEqual(rowHeight + box.padding - 1);
const legendBounds = (await legend.boundingBox())!;
const groupBounds = (await article.locator("[data-chart-group-tile]").boundingBox())!;
expect(legendBounds.y + legendBounds.height).toBeLessThanOrEqual(groupBounds.y + groupBounds.height + 1);
}
async function coordinateBounds(plot: Locator) {
return plot.locator("svg").evaluate((svg) => {
const width = svg.getBoundingClientRect().width;
const lines = [...svg.querySelectorAll("path")].filter((path) => getComputedStyle(path).stroke === "rgb(226, 232, 240)")
.map((path) => path.getBoundingClientRect()).filter((box) => box.width > width / 2 && box.height <= 1);
if (lines.length < 2) throw new Error("Expected horizontal value-axis grid lines to measure the actual coordinate area");
const x = Math.min(...lines.map((line) => line.x));
const y = Math.min(...lines.map((line) => line.y));
return { x, y, width: Math.max(...lines.map((line) => line.right)) - x, height: Math.max(...lines.map((line) => line.bottom)) - y, gridLines: lines.length };
});
}
async function expectNoHorizontalOverflow(manager: Locator) {
const measured = await manager.evaluate((node) => [node, ...node.querySelectorAll<HTMLElement>("[data-chart-manager-scroll], [data-chart-layout], [data-managed-chart]")]
.filter((element) => element.clientWidth > 0)
.map((element) => ({
label: element.getAttribute("data-chart-title") ?? element.getAttribute("aria-label") ?? element.tagName,
width: element.clientWidth,
scrollWidth: element.scrollWidth
})));
for (const element of measured) expect(element.scrollWidth - element.width, `${element.label} must not create a horizontal scrollbar`).toBeLessThanOrEqual(1);
return measured;
}
async function resolvedColor(element: Locator, token: string, property: string) {
return element.evaluate((node, { token, property }) => {
const probe = document.createElement("span");
probe.style.setProperty(property, `var(${token})`);
node.append(probe);
const color = getComputedStyle(probe).getPropertyValue(property);
probe.remove();
return color;
}, { token, property });
}
async function setup(page: Page, count: number) {
await page.route("**/@fs/**/noto-sans-sc-*.woff2", (route) => route.fulfill({
path: resolve("node_modules/@fontsource-variable/noto-sans-sc/files", basename(new URL(route.request().url()).pathname)), contentType: "font/woff2"
}));
const task = investigationFixture(1, "固定图表槽位验收");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
const panel = page.getByLabel("调查实时展示", { exact: true });
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
for (let index = 1; index <= count; index++) await mock.emit(task, chart(`P${index}`));
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(manager.locator("[data-managed-chart]")).toHaveCount(count);
return { manager, mock, task, panel };
}
test.use({ reducedMotion: "reduce", hasTouch: false, screenshot: "only-on-failure" });
test.setTimeout(90_000);
test("quick chart location highlights the position bar without an outer frame", async ({ page }, testInfo) => {
const { manager } = await setup(page, 4);
await page.evaluate(() => document.fonts.ready);
for (const width of [1440, 1600, 375]) {
await page.setViewportSize({ width, height: 900 });
const toolbar = manager.getByRole("toolbar", { name: "图表管理工具", exact: true });
const locate = toolbar.getByRole("combobox", { name: "快速定位图表", exact: true });
await expect(toolbar).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(locate).toHaveCSS("border-top-color", "rgba(0, 0, 0, 0)");
await expect(locate).toHaveCSS("height", "32px");
const locateBounds = (await locate.boundingBox())!;
expect(locateBounds.width).toBeLessThanOrEqual(288);
for (const control of await toolbar.getByRole("button").all()) {
const bounds = (await control.boundingBox())!;
expect(Math.abs(bounds.height - locateBounds.height)).toBeLessThanOrEqual(1);
if (width > 640) expect(Math.abs(bounds.y - locateBounds.y)).toBeLessThanOrEqual(1);
}
await expect(toolbar.getByRole("group", { name: "图表排列方式", exact: true })).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
expect(await toolbar.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
const target = card(manager, "P4");
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P4" }).click();
await expect(target).toBeFocused();
await expect(target).toHaveAttribute("data-chart-located", "true");
const hasVisibleOutline = () => target.evaluate((node) => {
const style = getComputedStyle(node);
return style.outlineStyle !== "none" && Number.parseFloat(style.outlineWidth) > 0 && style.outlineColor !== "rgba(0, 0, 0, 0)";
});
expect(await hasVisibleOutline()).toBe(false);
await expect(target).toHaveCSS("box-shadow", "none");
const bar = target.locator(":scope > div").first();
await expect(bar).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(target.getByText("已定位", { exact: true })).toBeVisible();
const bounds = await target.boundingBox();
await testInfo.attach(`chart-location-${width}`, { body: await manager.screenshot({ path: testInfo.outputPath(`chart-location-${width}.png`) }), contentType: "image/png" });
await expect(target).toHaveAttribute("data-chart-located", "false");
await expect(bar).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
expect(await hasVisibleOutline()).toBe(false);
expect(await target.boundingBox()).toEqual(bounds);
await locate.focus();
await locate.press("ArrowDown");
await page.getByRole("option").filter({ hasText: "合成监测 P4" }).focus();
await page.keyboard.press("Enter");
await expect(target).toBeFocused();
expect(await hasVisibleOutline()).toBe(false);
}
});
test("fixed chart slots swap without inserting or redrawing during pointer movement and fill vacancies before adding a row", async ({ page }, testInfo) => {
const { manager, mock, task } = await setup(page, 4);
const source = card(manager, "P1");
await manager.getByRole("button", { name: "四宫格布局", exact: true }).click();
await expect(slots(manager)).toHaveCount(4);
await card(manager, "P1").getByLabel("显示的数据序列").getByRole("button", { name: "参考(m)", exact: true }).click();
const instance = await card(manager, "P1").locator(".echarts-for-react").getAttribute("_echarts_instance_");
const chartsBefore = await page.locator(".echarts-for-react").count();
const handle = card(manager, "P1").getByRole("button", { name: "拖动图表 合成监测 P1", exact: true });
const positionBar = handle.locator("..");
const start = (await handle.boundingBox())!;
const bar = (await positionBar.boundingBox())!;
for (const [buttonEdge, barEdge] of [[start.x, bar.x], [start.y, bar.y], [start.x + start.width, bar.x + bar.width], [start.y + start.height, bar.y + bar.height]]) {
expect(Math.abs(buttonEdge - barEdge), "the drag button must cover every edge of the position bar").toBeLessThanOrEqual(1);
}
await expect(manager.getByRole("combobox", { name: /^移动图表 / })).toHaveCount(0);
await expect(manager.getByText("移动到", { exact: true })).toHaveCount(0);
const rightBlank = { x: start.width - 12, y: start.height / 2 };
await page.mouse.move(1, 1);
const highlight = source.locator("[data-chart-hover-highlight]");
await expect(highlight).toHaveCSS("opacity", "0");
await expect(highlight).toHaveCSS("pointer-events", "none");
const sourceBounds = (await source.boundingBox())!;
const highlightBounds = (await highlight.boundingBox())!;
for (const [highlightEdge, sourceEdge] of [[highlightBounds.x, sourceBounds.x], [highlightBounds.y, sourceBounds.y], [highlightBounds.x + highlightBounds.width, sourceBounds.x + sourceBounds.width], [highlightBounds.y + highlightBounds.height, sourceBounds.y + sourceBounds.height]]) {
expect(Math.abs(highlightEdge - sourceEdge), "the hover highlight must cover every edge of the whole chart card").toBeLessThanOrEqual(1);
}
const highlightBackground = await highlight.evaluate((node) => getComputedStyle(node).backgroundColor);
const highlightAlpha = Number(highlightBackground.match(/\/\s*([\d.]+)\s*\)$/)?.[1] ?? highlightBackground.match(/^rgba\(.+,\s*([\d.]+)\s*\)$/)?.[1] ?? 1);
expect(highlightAlpha, "the whole-card tint must have a real, subtle background").toBeGreaterThan(0);
expect(highlightAlpha).toBeLessThanOrEqual(0.06);
// Hover the chart body edge, away from both its title bar and ECharts tooltips.
await source.hover({ position: { x: sourceBounds.width - 6, y: sourceBounds.height / 2 } });
await expect(highlight).toHaveCSS("opacity", "1");
await testInfo.attach("whole-card-hover", { body: await source.screenshot({ path: testInfo.outputPath("whole-card-hover.png") }), contentType: "image/png" });
await page.mouse.move(1, 1);
await expect(highlight).toHaveCSS("opacity", "0");
const idleBackground = await handle.evaluate((node) => getComputedStyle(node).backgroundColor);
await handle.hover({ position: rightBlank });
await expect(highlight).toHaveCSS("opacity", "1");
await expect(handle).not.toHaveCSS("background-color", idleBackground);
expect(await handle.evaluate((node, point) => {
const bounds = node.getBoundingClientRect();
return document.elementFromPoint(bounds.x + point.x, bounds.y + point.y)?.closest("button") === node;
}, rightBlank)).toBe(true);
await testInfo.attach("position-bar-right-hover", { body: await positionBar.screenshot({ path: testInfo.outputPath("position-bar-right-hover.png") }), contentType: "image/png" });
const destination = (await manager.locator('[data-chart-slot="3"]').boundingBox())!;
await card(manager, "P1").evaluate(async (node) => {
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
Object.assign(window, { chartSwapSvgMutations: 0 });
const observer = new MutationObserver((records) => {
(window as unknown as { chartSwapSvgMutations: number }).chartSwapSvgMutations += records.length;
});
node.querySelectorAll(".echarts-for-react svg").forEach((svg) => observer.observe(svg, { subtree: true, attributes: true, childList: true }));
Object.assign(window, { stopChartSwapWatch: () => observer.disconnect() });
});
await page.mouse.move(start.x + rightBlank.x, start.y + rightBlank.y);
await page.mouse.down();
await page.mouse.move(start.x + rightBlank.x + 12, start.y + rightBlank.y + 8, { steps: 3 });
await page.mouse.move(destination.x + destination.width / 2, destination.y + destination.height / 2, { steps: 16 });
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
const during = await page.evaluate(() => {
const state = window as unknown as { chartSwapSvgMutations: number; stopChartSwapWatch: () => void };
state.stopChartSwapWatch();
return state.chartSwapSvgMutations;
});
expect(during).toBe(0);
await expect(source).toHaveAttribute("data-chart-dragging", "true");
await expect(highlight).toHaveCSS("opacity", "1");
await expect(manager.locator('[data-chart-slot="3"]')).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(source).toHaveCSS("opacity", "1");
await expect(handle).not.toHaveCSS("color", "rgb(255, 255, 255)");
await expect(handle).not.toHaveCSS("background-color", await resolvedColor(handle, "--action-blue", "background-color"));
const drawingOpacity = await source.locator(".echarts-for-react svg").evaluate((svg) => {
const ancestors = [];
for (let node: Element | null = svg; node; node = node.parentElement) {
ancestors.push({ element: node.tagName, className: node.getAttribute("class"), opacity: getComputedStyle(node).opacity });
if (node.matches("[data-managed-chart]")) break;
}
return ancestors;
});
for (const ancestor of drawingOpacity) expect(ancestor.opacity, `${ancestor.element} must keep the plot fully opaque during dragging`).toBe("1");
const overlay = page.locator("[data-chart-drag-preview]");
await expect(overlay).toBeVisible();
await expect(overlay).toHaveCSS("border-top-color", await resolvedColor(overlay, "--surface-border", "border-top-color"));
await expectNoHorizontalOverflow(manager);
await testInfo.attach("dragging-source", { body: await source.screenshot({ path: testInfo.outputPath("dragging-source.png") }), contentType: "image/png" });
await testInfo.attach("dragging-manager", { body: await manager.screenshot({ path: testInfo.outputPath("dragging-manager.png") }), contentType: "image/png" });
await testInfo.attach("dragging-plot-opacity", { body: JSON.stringify(drawingOpacity), contentType: "application/json" });
// A lightweight preview must not clone the chart engine while the original stays in its slot.
await expect(manager.locator(".echarts-for-react")).toHaveCount(4);
await expect(page.locator(".echarts-for-react")).toHaveCount(chartsBefore);
await page.mouse.up();
await expect(source).toHaveAttribute("data-chart-dragging", "false");
await expect.poll(() => positions(manager)).toEqual({ "合成监测 P1": 3, "合成监测 P2": 1, "合成监测 P3": 2, "合成监测 P4": 0 });
await expect(card(manager, "P1").locator(".echarts-for-react")).toHaveAttribute("_echarts_instance_", instance!);
await expect(card(manager, "P1").getByLabel("显示的数据序列").getByRole("button", { name: "参考(m)", exact: true })).toHaveAttribute("aria-pressed", "false");
await mock.emit(task, chart("P2", 17));
await card(manager, "P2").getByRole("tab", { name: "数据", exact: true }).click();
await expect(card(manager, "P2").getByRole("table")).toContainText("17");
await expect(card(manager, "P2")).toHaveAttribute("data-slot-index", "1");
await mock.emit(task, { action: "close_chart", chart_id: "P2" });
await expect(card(manager, "P2")).toHaveCount(0);
await expect(slots(manager)).toHaveCount(4);
await expect(card(manager, "P1")).toHaveAttribute("data-slot-index", "3");
await mock.emit(task, chart("P5"));
await expect(card(manager, "P5")).toHaveAttribute("data-slot-index", "1");
await mock.emit(task, chart("P6"));
await expect(card(manager, "P6")).toHaveAttribute("data-slot-index", "4");
await expect(card(manager, "P6")).toBeVisible();
await expect(card(manager, "P6")).not.toBeInViewport();
await expect(slots(manager)).toHaveCount(6);
await expect(manager.getByRole("button", { name: /[上下]一页图表/ })).toHaveCount(0);
const scroll = manager.locator("[data-chart-manager-scroll]");
await expect.poll(() => scroll.evaluate((node) => node.scrollHeight - node.clientHeight)).toBeGreaterThan(100);
await scroll.hover();
await page.mouse.wheel(0, 700);
await expect(card(manager, "P6")).toBeInViewport();
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P6" }).click();
await expect(card(manager, "P6")).toBeVisible();
await expect(card(manager, "P6")).toBeFocused();
await expect(slots(manager)).toHaveCount(6);
await expect.poll(() => scroll.evaluate((node) => node.scrollTop)).toBeGreaterThan(0);
await manager.getByRole("button", { name: "重置图表排列", exact: true }).click();
await expect.poll(() => scroll.evaluate((node) => node.scrollTop)).toBe(0);
await expect(card(manager, "P1")).toBeVisible();
await expect(card(manager, "P1").locator(".echarts-for-react")).toHaveAttribute("_echarts_instance_", instance!);
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("fixed-slots-desktop", { body: await manager.screenshot({ path: testInfo.outputPath("fixed-slots-desktop.png") }), contentType: "image/png" });
expect(mock.mutations).toEqual([]);
});
test("scrolling layouts adapt charts and retain empty places without turning into an insertion list", async ({ page }, testInfo) => {
const { manager, mock } = await setup(page, 3);
const dimensions: Record<string, { width: number; height: number }> = {};
for (const [label, layout, count] of [["单图布局", "one", 1], ["左右布局", "two", 2], ["四宫格布局", "four", 4]] as const) {
await manager.getByRole("button", { name: label, exact: true }).click();
await expect(manager.locator("[data-chart-layout]")).toHaveAttribute("data-chart-layout", layout);
await expect(slots(manager)).toHaveCount(layout === "one" ? 3 : 4);
const first = (await slots(manager).first().boundingBox())!;
dimensions[layout] = first;
if (count > 1) {
const second = (await slots(manager).nth(1).boundingBox())!;
expect(second.x).toBeGreaterThan(first.x + first.width - 1);
expect(Math.abs(second.y - first.y)).toBeLessThanOrEqual(1);
}
if (count === 4) {
const third = (await slots(manager).nth(2).boundingBox())!;
expect(third.y).toBeGreaterThan(first.y + first.height - 1);
expect(Math.abs(third.x - first.x)).toBeLessThanOrEqual(1);
expect(await manager.locator('[data-managed-chart][data-slot-index="3"]').count()).toBe(0);
}
expect(await manager.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
for (const article of await manager.locator("[data-managed-chart]:visible").all()) await expectCompleteLegendRow(article);
await testInfo.attach(`fixed-layout-${layout}`, { body: await manager.screenshot({ path: testInfo.outputPath(`fixed-layout-${layout}.png`) }), contentType: "image/png" });
}
expect(dimensions.one.width).toBeGreaterThan(dimensions.two.width);
expect(dimensions.one.height).toBeGreaterThan(dimensions.four.height);
const heading = manager.getByLabel("窗口标题栏,可用方向键移动,Shift 加方向键调整大小", { exact: true });
await heading.focus();
for (let index = 0; index < 20; index++) await page.keyboard.press("Shift+ArrowUp");
expect((await manager.boundingBox())!.height).toBeLessThanOrEqual(441);
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P3" }).click();
await expect(card(manager, "P3")).toBeFocused();
const viewport = (await manager.locator("[data-chart-manager-scroll]").boundingBox())!;
for (const target of [card(manager, "P3").getByRole("button", { name: "拖动图表 合成监测 P3", exact: true }), card(manager, "P3").getByRole("heading", { level: 3 })]) {
const bounds = (await target.boundingBox())!;
expect(bounds.y).toBeGreaterThanOrEqual(viewport.y - 1);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.y + viewport.height + 1);
}
expect(await manager.locator("[data-chart-manager-scroll]").evaluate((node) => node.scrollTop)).toBeGreaterThan(0);
await testInfo.attach("fixed-layout-short-locate", { body: await manager.screenshot({ path: testInfo.outputPath("fixed-layout-short-locate.png") }), contentType: "image/png" });
expect(mock.mutations).toEqual([]);
});
test("default keyboard dragging swaps fixed slots and narrow scrolling grids retain location and enlargement", async ({ page }, testInfo) => {
const { manager, mock, task } = await setup(page, 4);
await manager.getByRole("button", { name: "四宫格布局", exact: true }).click();
const source = card(manager, "P1");
const sourceBounds = (await source.boundingBox())!;
const targetBounds = (await manager.locator('[data-chart-slot="3"]').boundingBox())!;
const moveX = targetBounds.x + targetBounds.width / 2 - sourceBounds.x - sourceBounds.width / 2;
const moveY = targetBounds.y + targetBounds.height / 2 - sourceBounds.y - sourceBounds.height / 2;
await source.getByRole("button", { name: "拖动图表 合成监测 P1", exact: true }).focus();
await page.keyboard.press("Space");
await expect(source).toHaveAttribute("data-chart-dragging", "true");
await expect(source).toHaveCSS("opacity", "1");
// The SDK's default KeyboardSensor advances 50px with Shift + an arrow.
for (let step = 0; step < Math.round(Math.abs(moveX) / 50); step++) await page.keyboard.press(moveX > 0 ? "Shift+ArrowRight" : "Shift+ArrowLeft");
for (let step = 0; step < Math.round(Math.abs(moveY) / 50); step++) await page.keyboard.press(moveY > 0 ? "Shift+ArrowDown" : "Shift+ArrowUp");
await expect(manager.locator('[data-chart-slot="3"]')).toHaveClass(/border-primary/);
await page.keyboard.press("Space");
await expect.poll(() => positions(manager)).toEqual({ "合成监测 P1": 3, "合成监测 P2": 1, "合成监测 P3": 2, "合成监测 P4": 0 });
await expect(source).toHaveAttribute("data-chart-dragging", "false");
await expect(source).toHaveCSS("opacity", "1");
await mock.emit(task, chart("P5"));
await expect(card(manager, "P5")).toHaveAttribute("data-slot-index", "4");
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 812 });
await expect(manager.locator("[data-chart-layout]")).toHaveAttribute("data-chart-layout", "one");
await expect(slots(manager)).toHaveCount(5);
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P1" }).click();
await expect(card(manager, "P1")).toBeVisible();
await expect(card(manager, "P1")).toHaveAttribute("data-slot-index", "3");
await expect(card(manager, "P1")).toBeFocused();
const scroll = manager.locator("[data-chart-manager-scroll]");
await scroll.focus();
await scroll.press("End");
await expect(card(manager, "P5")).toBeInViewport();
await scroll.press("Home");
await expect(card(manager, "P4")).toBeInViewport();
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P1" }).click();
await expect(card(manager, "P1")).toBeInViewport();
expect(await manager.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await page.evaluate(() => document.fonts.ready);
await testInfo.attach(`fixed-slots-${width}`, { body: await manager.screenshot({ path: testInfo.outputPath(`fixed-slots-${width}.png`) }), contentType: "image/png" });
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P4" }).click();
await expect(card(manager, "P4")).toBeVisible();
await expect.poll(() => manager.locator("[data-chart-manager-scroll]").evaluate((node) => node.scrollTop)).toBeLessThanOrEqual(2);
}
await page.setViewportSize({ width: 1440, height: 900 });
await manager.getByRole("button", { name: "最大化窗口", exact: true }).click();
await expect(manager.locator("[data-chart-layout]")).toHaveAttribute("data-chart-layout", "four");
await expect(slots(manager)).toHaveCount(6);
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P1" }).click();
await card(manager, "P1").getByRole("button", { name: "放大查看 合成监测 P1", exact: true }).click();
await expect(page.getByRole("dialog", { name: "合成监测 P1", exact: true })).toBeVisible();
expect(mock.mutations).toEqual([]);
});
test("compound comparisons fit each slot with bottom scrolling legends and accessible object selection", async ({ page }, testInfo) => {
const { manager, mock, task } = await setup(page, 1);
for (const id of ["170486", "175618", "159673"]) await mock.emit(task, comparison(id));
await mock.emit(task, chart("P2"));
for (const id of ["542017", "486033", "524529"]) await mock.emit(task, comparison(id, true));
await expect(manager.locator("[data-managed-chart]")).toHaveCount(4);
const compound = manager.locator('[data-managed-chart][data-chart-title="压力七日同时刻对照(m)"]');
await expect(compound).toHaveAttribute("data-slot-index", "1");
await manager.getByRole("button", { name: "四宫格布局", exact: true }).click();
const objectButtons = compound.locator('aside[aria-label="选择图表对象"]');
await expect(objectButtons).toBeVisible();
await expect(objectButtons.getByRole("button")).toHaveCount(3);
await objectButtons.getByRole("button", { name: "切换对象 175618", exact: true }).click();
const plot = compound.getByRole("img", { name: "175618 压力七日同时刻对照(m)", exact: true });
await expect(plot).toBeVisible();
const legend = compound.getByLabel("图表图例", { exact: true });
await expect(legend.getByRole("button")).toHaveCount(8);
await page.evaluate(() => document.fonts.ready);
await expect.poll(() => plot.locator("svg").boundingBox().then((box) => box?.height ?? 0)).toBeGreaterThanOrEqual(100);
const geometry = async () => {
const slot = (await compound.boundingBox())!;
const drawing = (await plot.boundingBox())!;
const svg = (await plot.locator("svg").boundingBox())!;
const footer = (await legend.boundingBox())!;
const coordinates = await coordinateBounds(plot);
for (const box of [drawing, svg, footer]) {
expect(box.x).toBeGreaterThanOrEqual(slot.x - 1);
expect(box.y).toBeGreaterThanOrEqual(slot.y - 1);
expect(box.x + box.width).toBeLessThanOrEqual(slot.x + slot.width + 1);
expect(box.y + box.height).toBeLessThanOrEqual(slot.y + slot.height + 1);
}
expect(drawing.height).toBeGreaterThanOrEqual(100);
expect(coordinates.height, "compact axes must leave at least 40px for the actual plotted data").toBeGreaterThanOrEqual(40);
expect(footer.y).toBeGreaterThanOrEqual(drawing.y + drawing.height - 1);
expect(footer.height).toBeLessThanOrEqual(56);
await expectCompleteLegendRow(compound);
expect(slot.height).toBeGreaterThanOrEqual(234);
const overflow = await expectNoHorizontalOverflow(manager);
return { slot, drawing, svg, coordinates, footer, overflow };
};
const fourGeometry = await geometry();
const flow = manager.locator('[data-managed-chart][data-chart-title="管流七日同时刻对照(m³/h)"]');
const flowDrawing = flow.getByRole("img", { name: "542017 管流七日同时刻对照(m³/h)", exact: true });
await expect(flowDrawing).toBeVisible();
await expectCompleteLegendRow(flow);
await expect(flow.getByLabel("图表图例", { exact: true }).getByRole("button")).toHaveCount(8);
expect((await coordinateBounds(flowDrawing)).height, "large flow values and eight legend entries must preserve the coordinate area").toBeGreaterThanOrEqual(40);
expect((await objectButtons.boundingBox())!.x).toBeGreaterThanOrEqual(fourGeometry.drawing.x + fourGeometry.drawing.width);
const scroll = manager.locator("[data-chart-manager-scroll]");
const scrollBefore = await scroll.evaluate((node) => node.scrollTop);
const legendScroll = await legend.evaluate((node) => {
node.scrollTop = node.scrollHeight;
return { height: node.clientHeight, content: node.scrollHeight, top: node.scrollTop, overflow: getComputedStyle(node).overflowY };
});
expect(legendScroll.overflow).toBe("auto");
expect(legendScroll.content).toBeGreaterThan(legendScroll.height);
expect(legendScroll.top).toBeGreaterThan(0);
expect(await scroll.evaluate((node) => node.scrollTop)).toBe(scrollBefore);
await legend.getByRole("button", { name: "175618 2026-07-15", exact: true }).click();
await expect(legend.getByRole("button", { name: "175618 2026-07-15", exact: true })).toHaveAttribute("aria-pressed", "false");
await testInfo.attach("compound-fixed-four", { body: await manager.screenshot({ path: testInfo.outputPath("compound-fixed-four.png") }), contentType: "image/png" });
await manager.getByRole("button", { name: "单图布局", exact: true }).click();
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "压力七日同时刻对照(m)" }).click();
await expect(compound).toBeVisible();
await expect(objectButtons).toBeVisible();
await expect(compound.getByRole("combobox", { name: "选择图表对象", exact: true })).toBeHidden();
await expect(compound).toBeFocused();
const oneGeometry = await geometry();
await testInfo.attach("compound-fixed-one", { body: await manager.screenshot({ path: testInfo.outputPath("compound-fixed-one.png") }), contentType: "image/png" });
await page.setViewportSize({ width: 375, height: 812 });
await expect(manager.locator("[data-chart-layout]")).toHaveAttribute("data-chart-layout", "one");
await expect(compound).toBeVisible();
await expect(objectButtons).toBeHidden();
await compound.getByRole("combobox", { name: "选择图表对象", exact: true }).click();
await page.getByRole("option", { name: "159673", exact: true }).click();
await expect(compound.getByRole("img", { name: "159673 压力七日同时刻对照(m)", exact: true })).toBeVisible();
await expect.poll(() => compound.getByRole("img", { name: "159673 压力七日同时刻对照(m)", exact: true }).locator("svg").boundingBox().then((box) => box?.height ?? 0)).toBeGreaterThanOrEqual(100);
const narrowPlot = (await compound.getByRole("img", { name: "159673 压力七日同时刻对照(m)", exact: true }).boundingBox())!;
const narrowSvg = (await compound.getByRole("img", { name: "159673 压力七日同时刻对照(m)", exact: true }).locator("svg").boundingBox())!;
const narrowSlot = (await compound.boundingBox())!;
const narrowLegend = (await legend.boundingBox())!;
const narrowCoordinates = await coordinateBounds(compound.getByRole("img", { name: "159673 压力七日同时刻对照(m)", exact: true }));
expect(narrowPlot.height).toBeGreaterThanOrEqual(100);
expect(narrowPlot.x + narrowPlot.width).toBeLessThanOrEqual(narrowSlot.x + narrowSlot.width + 1);
expect(narrowSvg.x + narrowSvg.width).toBeLessThanOrEqual(narrowSlot.x + narrowSlot.width + 1);
expect(narrowSvg.y + narrowSvg.height).toBeLessThanOrEqual(narrowSlot.y + narrowSlot.height + 1);
expect(narrowLegend.y).toBeGreaterThanOrEqual(narrowPlot.y + narrowPlot.height - 1);
expect(narrowLegend.y + narrowLegend.height).toBeLessThanOrEqual(narrowSlot.y + narrowSlot.height + 1);
expect(await manager.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await testInfo.attach("compound-fixed-narrow", { body: await manager.screenshot({ path: testInfo.outputPath("compound-fixed-narrow.png") }), contentType: "image/png" });
const geometryPath = testInfo.outputPath("compound-slot-geometry.json");
await writeFile(geometryPath, JSON.stringify({ four: fourGeometry, one: oneGeometry, legendScroll, narrow: { slot: narrowSlot, drawing: narrowPlot, svg: narrowSvg, coordinates: narrowCoordinates, footer: narrowLegend } }, null, 2));
await testInfo.attach("compound-slot-geometry", { path: geometryPath, contentType: "application/json" });
expect(mock.mutations).toEqual([]);
});