Files
SystemSimulationApp/frontend/tests/e2e/fit-view.spec.ts
T

914 lines
31 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
expectAllNodesInsideCanvas,
prepareApp,
resultSnapshot,
wideProject,
} from "./fixtures";
type ImportedProjectEdge = {
id: string;
source: string;
target: string;
sourceHandle: string;
targetHandle: string;
data?: { isContactEdge?: boolean };
};
type ImportedProject = {
nodes: Array<{ id: string }>;
edges: ImportedProjectEdge[];
};
const MQL_8_PROJECT_PATH = fileURLToPath(
new URL("../../../tests/data/test-mql-8-corrected.json", import.meta.url),
);
const mql8Project = JSON.parse(
readFileSync(MQL_8_PROJECT_PATH, "utf8"),
) as ImportedProject;
const mql8ResultSnapshot = {
...resultSnapshot,
id: "e2e-mql-8-result-system-bridges",
project: mql8Project as unknown as typeof resultSnapshot.project,
};
async function readViewport(page: Page, canvasSelector: string) {
return page
.locator(`${canvasSelector} .react-flow__viewport`)
.evaluate((viewport) => {
const values =
getComputedStyle(viewport)
.transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)
?.map(Number) ?? [];
return {
x: values[4] ?? 0,
y: values[5] ?? 0,
zoom: values[0] ?? 1,
};
});
}
async function readModelingViewport(page: Page) {
return readViewport(page, ".flow-canvas");
}
async function readVisibleEdgeEndpointAlignment(
page: Page,
edges: ImportedProjectEdge[],
) {
return page.evaluate((projectEdges) => {
const nodeElements = new Map(
Array.from(
document.querySelectorAll<HTMLElement>(
".flow-canvas .react-flow__node[data-id]",
),
).flatMap((element) =>
element.dataset.id ? [[element.dataset.id, element] as const] : [],
),
);
const edgeElements = new Map(
Array.from(
document.querySelectorAll<SVGGElement>(
".flow-canvas .react-flow__edge[data-id]",
),
).flatMap((element) =>
element.dataset.id ? [[element.dataset.id, element] as const] : [],
),
);
const measurements: Array<{
edgeId: string;
sourceDistance: number;
targetDistance: number;
}> = [];
const missingHandles: string[] = [];
for (const edge of projectEdges) {
if (edge.data?.isContactEdge === true) {
continue;
}
const path = edgeElements
.get(edge.id)
?.querySelector<SVGPathElement>(".react-flow__edge-path");
if (!path) {
// A geometrically touching edge is intentionally rendered without a path.
continue;
}
const sourceHandle = Array.from(
nodeElements
.get(edge.source)
?.querySelectorAll<HTMLElement>(".port-handle[data-port-name]") ?? [],
).find((handle) => handle.dataset.portName === edge.sourceHandle);
const targetHandle = Array.from(
nodeElements
.get(edge.target)
?.querySelectorAll<HTMLElement>(".port-handle[data-port-name]") ?? [],
).find((handle) => handle.dataset.portName === edge.targetHandle);
const screenMatrix = path.getScreenCTM();
if (!sourceHandle || !targetHandle || !screenMatrix) {
missingHandles.push(edge.id);
continue;
}
const pathLength = path.getTotalLength();
const sourcePoint = path.getPointAtLength(0).matrixTransform(screenMatrix);
const targetPoint = path
.getPointAtLength(pathLength)
.matrixTransform(screenMatrix);
const sourceBounds = sourceHandle.getBoundingClientRect();
const targetBounds = targetHandle.getBoundingClientRect();
const sourceCenter = {
x: sourceBounds.left + sourceBounds.width / 2,
y: sourceBounds.top + sourceBounds.height / 2,
};
const targetCenter = {
x: targetBounds.left + targetBounds.width / 2,
y: targetBounds.top + targetBounds.height / 2,
};
measurements.push({
edgeId: edge.id,
sourceDistance: Math.hypot(
sourcePoint.x - sourceCenter.x,
sourcePoint.y - sourceCenter.y,
),
targetDistance: Math.hypot(
targetPoint.x - targetCenter.x,
targetPoint.y - targetCenter.y,
),
});
}
const worst = measurements
.map((measurement) => ({
...measurement,
maxDistance: Math.max(
measurement.sourceDistance,
measurement.targetDistance,
),
}))
.sort((first, second) => second.maxDistance - first.maxDistance)
.slice(0, 8);
return {
checkedEdgeCount: measurements.length,
maxDistance: worst[0]?.maxDistance ?? Number.POSITIVE_INFINITY,
missingHandles,
worst,
};
}, edges);
}
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
await prepareApp(page);
await page.goto("/");
const viewport = page.locator(".flow-canvas .react-flow__viewport");
await expect(page.locator(".flow-canvas .react-flow__controls")).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__attribution")).toHaveCount(0);
const pane = page.locator(".flow-canvas .react-flow__pane");
const paneBox = await pane.boundingBox();
expect(paneBox).not.toBeNull();
await page.mouse.move(
paneBox!.x + paneBox!.width / 2,
paneBox!.y + paneBox!.height / 2,
);
await page.keyboard.down("Control");
await page.mouse.wheel(0, 240);
await page.keyboard.up("Control");
await page.waitForTimeout(250);
const viewportBeforeDrop = await viewport.getAttribute("style");
expect(viewportBeforeDrop).toBeTruthy();
await page
.getByRole("button", { name: "通用测试元件", exact: true })
.dragTo(page.locator(".flow-canvas .react-flow__pane"));
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(1);
await page.waitForTimeout(250);
await expect(viewport).toHaveAttribute("style", viewportBeforeDrop!);
await page.getByRole("button", { name: "适配视图", exact: true }).click();
await expect
.poll(() => viewport.getAttribute("style"))
.not.toBe(viewportBeforeDrop);
});
test("普通滚轮纵向平移,Ctrl 滚轮缩放且滚动条和中键拖动保持同步", async ({
page,
}) => {
await prepareApp(page);
await page.goto("/");
const canvas = page.locator(".flow-canvas");
const pane = canvas.locator(".react-flow__pane");
const horizontalScrollbar = page.getByRole("scrollbar", {
name: "水平滚动画布",
});
const verticalScrollbar = page.getByRole("scrollbar", {
name: "垂直滚动画布",
});
await expect(horizontalScrollbar).toBeVisible();
await expect(verticalScrollbar).toBeVisible();
const horizontalBox = await horizontalScrollbar.boundingBox();
const verticalBox = await verticalScrollbar.boundingBox();
expect(horizontalBox).not.toBeNull();
expect(verticalBox).not.toBeNull();
expect(horizontalBox!.height).toBeGreaterThanOrEqual(8);
expect(horizontalBox!.height).toBeLessThanOrEqual(12);
expect(verticalBox!.width).toBeGreaterThanOrEqual(8);
expect(verticalBox!.width).toBeLessThanOrEqual(12);
const paneBox = await pane.boundingBox();
expect(paneBox).not.toBeNull();
const pointer = {
x: paneBox!.x + paneBox!.width * 0.55,
y: paneBox!.y + paneBox!.height * 0.45,
};
await page.mouse.move(pointer.x, pointer.y);
const beforeWheel = await readModelingViewport(page);
const beforeVerticalScroll = await verticalScrollbar.evaluate(
(element) => element.scrollTop,
);
await page.mouse.wheel(0, 180);
await expect
.poll(async () => (await readModelingViewport(page)).y)
.not.toBeCloseTo(beforeWheel.y, 1);
const afterWheel = await readModelingViewport(page);
expect(afterWheel.x).toBeCloseTo(beforeWheel.x, 1);
expect(afterWheel.zoom).toBeCloseTo(beforeWheel.zoom, 3);
await expect
.poll(() => verticalScrollbar.evaluate((element) => element.scrollTop))
.not.toBeCloseTo(beforeVerticalScroll, 1);
const beforeZoom = await readModelingViewport(page);
const horizontalBeforeZoom = await horizontalScrollbar.evaluate(
(element) => element.scrollLeft,
);
const verticalBeforeZoom = await verticalScrollbar.evaluate(
(element) => element.scrollTop,
);
await page.keyboard.down("Control");
await page.mouse.wheel(0, -180);
await page.keyboard.up("Control");
await expect
.poll(async () => (await readModelingViewport(page)).zoom)
.not.toBeCloseTo(beforeZoom.zoom, 2);
await expect.poll(async () => {
const horizontalAfterZoom = await horizontalScrollbar.evaluate(
(element) => element.scrollLeft,
);
const verticalAfterZoom = await verticalScrollbar.evaluate(
(element) => element.scrollTop,
);
return Math.hypot(
horizontalAfterZoom - horizontalBeforeZoom,
verticalAfterZoom - verticalBeforeZoom,
);
}).toBeGreaterThan(1);
const beforeMiddleDrag = await readModelingViewport(page);
await page.mouse.move(pointer.x, pointer.y);
await page.mouse.down({ button: "middle" });
await page.mouse.move(pointer.x + 70, pointer.y + 45, { steps: 4 });
await page.mouse.up({ button: "middle" });
await expect
.poll(async () => {
const next = await readModelingViewport(page);
return Math.hypot(next.x - beforeMiddleDrag.x, next.y - beforeMiddleDrag.y);
})
.toBeGreaterThan(20);
const afterMiddleDrag = await readModelingViewport(page);
expect(afterMiddleDrag.zoom).toBeCloseTo(beforeMiddleDrag.zoom, 3);
await expect(canvas.locator(".react-flow__selection")).toHaveCount(0);
await verticalScrollbar.focus();
const beforeKeyboardScroll = await verticalScrollbar.evaluate(
(element) => element.scrollTop,
);
await page.keyboard.press("ArrowDown");
await expect
.poll(() => verticalScrollbar.evaluate((element) => element.scrollTop))
.toBeGreaterThan(beforeKeyboardScroll);
const beforeScrollbarDrag = await readModelingViewport(page);
await horizontalScrollbar.evaluate((element) => {
element.scrollLeft += 120;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect
.poll(async () => (await readModelingViewport(page)).x)
.not.toBeCloseTo(beforeScrollbarDrag.x, 1);
const afterScrollbarDrag = await readModelingViewport(page);
expect(afterScrollbarDrag.y).toBeCloseTo(beforeScrollbarDrag.y, 1);
expect(afterScrollbarDrag.zoom).toBeCloseTo(beforeScrollbarDrag.zoom, 3);
const horizontalMaximum = await horizontalScrollbar.evaluate(
(element) => element.scrollWidth - element.clientWidth,
);
const verticalMaximum = await verticalScrollbar.evaluate(
(element) => element.scrollHeight - element.clientHeight,
);
await expect(horizontalScrollbar).toHaveAttribute(
"aria-valuemax",
String(Math.round(horizontalMaximum)),
);
await expect(verticalScrollbar).toHaveAttribute(
"aria-valuemax",
String(Math.round(verticalMaximum)),
);
});
test("加载本地工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((project) => {
window.localStorage.setItem(
"system-simulation-flow:autosave",
JSON.stringify(project),
);
}, wideProject);
await page.goto("/");
await page.getByRole("button", { name: "加载工程" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel.locator(".simulation-console-dock-summary")).toContainText(
"已按旧工程规则读取参数",
);
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toContainText("已加载最后一次浏览器存档");
});
test("导入工程 JSON 后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.goto("/");
await page.locator('input[type="file"]').setInputFiles({
name: "wide-project.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(wideProject)),
});
await expectAllNodesInsideCanvas(page, ".flow-canvas");
});
test("导入大型工程 JSON 后非接触连线端点与端口保持对齐", async ({
page,
}) => {
await prepareApp(page);
await page.goto("/");
await page.locator('input[type="file"]').setInputFiles(MQL_8_PROJECT_PATH);
await expectAllNodesInsideCanvas(
page,
".flow-canvas",
mql8Project.nodes.length,
);
await expect
.poll(
async () =>
(
await readVisibleEdgeEndpointAlignment(page, mql8Project.edges)
).checkedEdgeCount,
{ timeout: 15_000 },
)
.toBeGreaterThan(20);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
const alignment = await readVisibleEdgeEndpointAlignment(
page,
mql8Project.edges,
);
expect(alignment.missingHandles).toEqual([]);
expect(alignment.checkedEdgeCount).toBeGreaterThan(20);
expect(
alignment.maxDistance,
`偏移最大的连线端点:${JSON.stringify(alignment.worst, null, 2)}`,
// React Flow anchors to the outer edge of the 6 px port glyph rather
// than its visual center; allow that radius plus subpixel rounding.
).toBeLessThanOrEqual(4);
});
test("大型工程连续缩放时滚动条不逐帧重排且停稳后同步", async ({ page }) => {
await prepareApp(page);
await page.goto("/");
await page.locator('input[type="file"]').setInputFiles(MQL_8_PROJECT_PATH);
await expectAllNodesInsideCanvas(page, ".flow-canvas", mql8Project.nodes.length);
await expect(page.getByRole("scrollbar", { name: "水平滚动画布" }))
.toHaveAttribute("aria-valuemax", /[1-9]/);
const changes = await page.evaluate(async () => {
const canvas = document.querySelector<HTMLElement>(".flow-canvas")!;
const pane = canvas.querySelector<HTMLElement>(".react-flow__pane")!;
const viewport = canvas.querySelector<HTMLElement>(".react-flow__viewport")!;
const scrollbars = canvas.querySelector<HTMLElement>(".flow-viewport-scrollbars")!;
const bounds = canvas.getBoundingClientRect();
let viewportChanges = 0;
let scrollbarChanges = 0;
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.target === viewport) viewportChanges++;
if (scrollbars.contains(mutation.target)) scrollbarChanges++;
}
});
observer.observe(viewport, { attributes: true, attributeFilter: ["style"] });
observer.observe(scrollbars, {
attributes: true, attributeFilter: ["style"], subtree: true,
});
// 连续输入覆盖整个手势,避免驱动侧等待导致其被拆成多个独立缩放。
for (let index = 0; index < 12; index++) {
pane.dispatchEvent(new WheelEvent("wheel", {
bubbles: true, cancelable: true, ctrlKey: true, deltaY: -8,
clientX: bounds.x + bounds.width / 2,
clientY: bounds.y + bounds.height / 2,
}));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
observer.disconnect();
return { viewportChanges, scrollbarChanges };
});
expect(changes.viewportChanges).toBeGreaterThan(5);
expect(changes.scrollbarChanges).toBeLessThan(changes.viewportChanges / 2);
await expect.poll(async () => {
const viewport = await readModelingViewport(page);
const scale = await page.locator(".flow-canvas").evaluate((canvas) =>
Number(canvas.style.getPropertyValue("--port-handle-hit-scale")),
);
return Math.abs(scale * viewport.zoom - 1);
}).toBeLessThan(0.01);
const alignment = await readVisibleEdgeEndpointAlignment(page, mql8Project.edges);
expect(alignment.missingHandles).toEqual([]);
expect(alignment.checkedEdgeCount).toBeGreaterThan(20);
expect(alignment.maxDistance).toBeLessThanOrEqual(4);
const zoom = (await readModelingViewport(page)).zoom;
const horizontal = page.getByRole("scrollbar", { name: "水平滚动画布" });
await horizontal.focus();
const before = await readModelingViewport(page);
await horizontal.press("ArrowRight");
await expect.poll(async () => (await readModelingViewport(page)).x).toBeLessThan(before.x);
expect((await readModelingViewport(page)).zoom).toBeCloseTo(zoom, 5);
});
test("恢复自动保存工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((project) => {
window.localStorage.setItem(
"system-simulation-flow:autosave",
JSON.stringify(project),
);
}, wideProject);
await page.goto("/");
await page.getByRole("button", { name: "恢复", exact: true }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel.locator(".simulation-console-dock-summary")).toContainText(
"已按旧工程规则读取参数",
);
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toContainText("已恢复上次自动保存的工程");
});
test("切换到结果页时自动适配只读系统图,旧网格线设置不再生效", async ({ page }) => {
await prepareApp(page);
await page.addInitScript(({ project, snapshot }) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
window.localStorage.setItem(
"system-simulation-flow:autosave",
JSON.stringify(project),
);
window.localStorage.setItem(
"system-simulation-flow:canvas-grid-visibility",
JSON.stringify({ lines: true, dots: false }),
);
}, { project: wideProject, snapshot: resultSnapshot });
await page.goto("/");
await page.getByRole("button", { name: "加载工程" }).click();
await expect(
page.locator(
'.flow-canvas .react-flow__background pattern[id$="major-grid-lines"]',
),
).toHaveCount(0);
await expect(
page.locator(
'.flow-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
),
).toHaveCount(0);
const freeModelingHandle = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-port-name="port_a"]',
);
await freeModelingHandle.dispatchEvent("click");
await expect(freeModelingHandle).toHaveAttribute(
"data-connection-state",
"origin",
);
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await expect(
page.locator(
'.results-system-canvas .react-flow__background pattern[id$="major-grid-lines"]',
),
).toHaveCount(0);
await expect(
page.locator(
'.results-system-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
),
).toHaveCount(0);
await expect(
page.locator(".results-system-canvas .react-flow__controls"),
).toHaveCount(0);
const fitSystemViewButton = page.getByRole("button", {
name: "适应系统图窗口",
exact: true,
});
await expect(fitSystemViewButton).toBeVisible();
await expect(
page.locator(".results-system-canvas .react-flow__attribution"),
).toHaveCount(0);
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toHaveCount(0);
const resultPane = page.locator(".results-system-canvas .react-flow__pane");
const resultPaneBox = await resultPane.boundingBox();
expect(resultPaneBox).not.toBeNull();
const fittedResultViewport = await readViewport(
page,
".results-system-canvas",
);
await page.mouse.move(
resultPaneBox!.x + resultPaneBox!.width / 2,
resultPaneBox!.y + resultPaneBox!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
resultPaneBox!.x + resultPaneBox!.width / 2 + 180,
resultPaneBox!.y + resultPaneBox!.height / 2 + 90,
{ steps: 5 },
);
await page.mouse.up();
await expect
.poll(async () => {
const viewport = await readViewport(page, ".results-system-canvas");
return Math.hypot(
viewport.x - fittedResultViewport.x,
viewport.y - fittedResultViewport.y,
);
})
.toBeGreaterThan(100);
const pannedResultViewport = await readViewport(
page,
".results-system-canvas",
);
await fitSystemViewButton.click();
await expect
.poll(async () => {
const viewport = await readViewport(page, ".results-system-canvas");
return Math.hypot(
viewport.x - pannedResultViewport.x,
viewport.y - pannedResultViewport.y,
);
})
.toBeGreaterThan(100);
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await page.getByRole("tab", { name: "建模" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
await expect(freeModelingHandle).toHaveAttribute(
"data-connection-state",
"idle",
);
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toBeVisible();
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toHaveCount(0);
await expect(page.locator(".results-system-canvas .react-flow__edge")).toHaveCount(
wideProject.edges.length,
);
const connectedResultHandles = page.locator(
'.results-system-canvas .port-handle[data-connected="true"]',
);
const idleResultHandles = page.locator(
'.results-system-canvas .port-handle[data-connected="false"]',
);
await expect(connectedResultHandles).toHaveCount(
wideProject.edges.length * 2,
);
await expect(idleResultHandles).toHaveCount(2);
await expect(connectedResultHandles.first()).toHaveCSS("opacity", "0");
await expect(
idleResultHandles.locator('[data-port-glyph-shape="circle"]'),
).toHaveCount(2);
const resultViewportZoom = await page
.locator(".results-system-canvas .react-flow__viewport")
.evaluate(
(element) =>
new DOMMatrixReadOnly(getComputedStyle(element).transform).a,
);
const idleResultPortShapes = await idleResultHandles.evaluateAll((elements) =>
elements.map((element) => {
const bounds = element.getBoundingClientRect();
const glyphBounds = element
.querySelector<SVGSVGElement>(".port-handle-glyph")
?.getBoundingClientRect();
const circle = element.querySelector<SVGCircleElement>(
'[data-port-glyph-shape="circle"]',
);
return {
circle: circle
? {
cx: circle.cx.baseVal.value,
cy: circle.cy.baseVal.value,
r: circle.r.baseVal.value,
}
: null,
glyphHeight: glyphBounds?.height ?? null,
glyphWidth: glyphBounds?.width ?? null,
height: bounds.height,
width: bounds.width,
};
}),
);
for (const shape of idleResultPortShapes) {
expect(Math.abs(shape.width - 6 * resultViewportZoom)).toBeLessThan(0.05);
expect(Math.abs(shape.height - 6 * resultViewportZoom)).toBeLessThan(0.05);
expect(shape.glyphWidth).not.toBeNull();
expect(shape.glyphHeight).not.toBeNull();
expect(
Math.abs(shape.glyphWidth! - 6 * resultViewportZoom),
).toBeLessThan(0.1);
expect(
Math.abs(shape.glyphHeight! - 6 * resultViewportZoom),
).toBeLessThan(0.1);
expect(Math.abs(shape.glyphWidth! - shape.glyphHeight!)).toBeLessThan(0.01);
expect(shape.circle).toEqual({ cx: 3, cy: 3, r: 2.5 });
}
});
test("结果页系统图为真实大型工程的非连接交叉线显示线桥", async ({
page,
}) => {
await prepareApp(page);
await page.addInitScript((snapshot) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
}, mql8ResultSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(
page.locator(".results-system-canvas .react-flow__node"),
).toHaveCount(mql8Project.nodes.length);
// In the AME-corrected layout P4 #5 crosses the non-connected vertical line.
// P4 #1 used by the old fixture no longer has that crossing after rotation.
await expect(
page.locator(
'.results-system-canvas .react-flow__edge[data-id="edge-amesim_p4node2_5-port_3-amesim_pnl0001_17-port_2-1786949374542"] .react-flow__edge-path',
),
).toHaveAttribute("d", / Q /);
});
test("从结果页返回建模页后保留用户调整的建模视口", async ({ page }) => {
await prepareApp(page);
await page.addInitScript(({ project, snapshot }) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
window.localStorage.setItem(
"system-simulation-flow:autosave",
JSON.stringify(project),
);
}, { project: wideProject, snapshot: resultSnapshot });
await page.goto("/");
await page.getByRole("button", { name: "加载工程" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
const modelingPane = page.locator(".flow-canvas .react-flow__pane");
const modelingPaneBox = await modelingPane.boundingBox();
expect(modelingPaneBox).not.toBeNull();
const fittedViewport = await readModelingViewport(page);
const pointer = {
x: modelingPaneBox!.x + modelingPaneBox!.width * 0.55,
y: modelingPaneBox!.y + modelingPaneBox!.height * 0.45,
};
await page.mouse.move(pointer.x, pointer.y);
await page.mouse.down({ button: "middle" });
await page.mouse.move(pointer.x + 180, pointer.y + 90, { steps: 5 });
await page.mouse.up({ button: "middle" });
await expect
.poll(async () => {
const viewport = await readModelingViewport(page);
return Math.hypot(
viewport.x - fittedViewport.x,
viewport.y - fittedViewport.y,
);
})
.toBeGreaterThan(100);
const beforeZoom = await readModelingViewport(page);
await page.keyboard.down("Control");
await page.mouse.wheel(0, -180);
await page.keyboard.up("Control");
await expect
.poll(async () => (await readModelingViewport(page)).zoom)
.not.toBeCloseTo(beforeZoom.zoom, 2);
const adjustedViewport = await readModelingViewport(page);
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await page.getByRole("tab", { name: "建模" }).click();
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(
wideProject.nodes.length,
);
await page.waitForTimeout(250);
const restoredViewport = await readModelingViewport(page);
expect(
Math.hypot(
restoredViewport.x - adjustedViewport.x,
restoredViewport.y - adjustedViewport.y,
),
).toBeLessThan(2);
expect(restoredViewport.zoom).toBeCloseTo(adjustedViewport.zoom, 3);
});
test("结果页专用图标只按实际包络命中被相邻节点包围的元件", async ({
page,
}) => {
await prepareApp(page);
const pneumaticPort = (name: string, side: "left" | "right") => ({
name,
kind: "physical",
domain: "pneumatic",
nominalRole: "bidirectional",
positiveFlowDirection: "intoComponent",
side,
});
const pnplNode = (
id: string,
position: { x: number; y: number },
rotation: 0 | 90 | 180 | 270,
) => ({
id,
type: "simulationComponent",
position,
data: {
label: id,
componentType: "amesim_pnpl01",
modelType: "amesim_pnpl01",
modelVersion: "0.1.0",
ports: [pneumaticPort("port_1", "left")],
parameters: {},
parameterUnits: {},
parameterScientificNotation: {},
rotation,
mirrored: false,
},
});
const targetNodeId = "amesim_pnch012_2";
const overlapProject = {
...wideProject,
name: "overlapped-symbols",
nodes: [
{
id: "generic_sensor_0",
type: "simulationComponent",
position: { x: 360, y: 390 },
data: {
label: "generic_sensor_0",
componentType: "generic_sensor",
modelType: "generic_sensor",
modelVersion: "1.0.0",
symbol: "symbol-not-registered",
ports: [],
parameters: {},
parameterUnits: {},
parameterScientificNotation: {},
rotation: 0,
mirrored: false,
},
},
{
id: targetNodeId,
type: "simulationComponent",
position: { x: 569.2129060235693, y: 515.6448805401301 },
data: {
label: targetNodeId,
componentType: "amesim_pnch012",
modelType: "amesim_pnch012",
modelVersion: "0.1.0",
ports: [
pneumaticPort("port_1", "left"),
pneumaticPort("port_2", "right"),
pneumaticPort("port_3", "left"),
pneumaticPort("port_4", "right"),
],
parameters: {},
parameterUnits: {},
parameterScientificNotation: {},
rotation: 0,
mirrored: false,
},
},
pnplNode(
"amesim_pnpl01_7",
{ x: 535.6129060235693, y: 523.6448805401301 },
180,
),
pnplNode(
"amesim_pnpl01_8",
{ x: 587.2129060235693, y: 555.2448805401301 },
90,
),
pnplNode(
"amesim_pnpl01_9",
{ x: 618.8129060235693, y: 523.6448805401301 },
0,
),
],
edges: [],
};
const overlapSnapshot = {
...resultSnapshot,
id: "overlapped-symbols-result",
project: overlapProject,
};
await page.addInitScript((snapshot) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
}, overlapSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(
page,
".results-system-canvas",
overlapProject.nodes.length,
);
const targetNode = page.locator(
`.results-system-canvas .react-flow__node[data-id="${targetNodeId}"]`,
);
const targetEnvelope = targetNode.locator('[data-symbol-envelope="true"]');
await expect(targetNode).toHaveClass(/component-icon-node/);
await expect(targetNode).toHaveCSS("pointer-events", "none");
await expect(targetEnvelope).toHaveCSS("pointer-events", "auto");
const pane = page.locator(".results-system-canvas .react-flow__pane");
const paneBox = await pane.boundingBox();
expect(paneBox).not.toBeNull();
await page.mouse.click(paneBox!.x + 6, paneBox!.y + 6);
await expect(targetNode).not.toHaveClass(/selected/);
const envelopeBox = await targetEnvelope.boundingBox();
expect(envelopeBox).not.toBeNull();
const targetPoint = {
x: envelopeBox!.x + envelopeBox!.width / 2,
y: envelopeBox!.y + envelopeBox!.height / 2,
};
const hitNodeId = await page.evaluate(({ x, y }) => {
const element = document.elementFromPoint(x, y);
return element?.closest<HTMLElement>(".react-flow__node")?.dataset.id ?? null;
}, targetPoint);
expect(hitNodeId).toBe(targetNodeId);
await page.mouse.click(targetPoint.x, targetPoint.y);
await expect(targetNode).toHaveClass(/selected/);
await expect(
page.locator(
'.results-system-canvas .react-flow__node[data-id^="amesim_pnpl01_"].selected',
),
).toHaveCount(0);
});