feat(charts): manage investigation charts in a scrolling workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m7s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m8s

This commit is contained in:
2026-11-10 17:56:23 +08:00
parent 865491cb23
commit 0ae0a41e61
80 changed files with 4672 additions and 470 deletions
@@ -0,0 +1,480 @@
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([]);
});