915 lines
31 KiB
TypeScript
915 lines
31 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { existsSync, 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_PATHS = [
|
|
new URL("../../../tests/data/test-mql-8%20.json", import.meta.url),
|
|
new URL("../../../tests/data/test-mql-8.json", import.meta.url),
|
|
].map(fileURLToPath);
|
|
const MQL_8_PROJECT_PATH =
|
|
MQL_8_PROJECT_PATHS.find(existsSync) ?? MQL_8_PROJECT_PATHS[0];
|
|
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:project:demo-system",
|
|
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: "恢复" }).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:project:demo-system",
|
|
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(1);
|
|
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(1);
|
|
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)).toBeLessThan(0.05);
|
|
expect(Math.abs(shape.height - 6)).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);
|
|
await expect(
|
|
page.locator(
|
|
'.results-system-canvas .react-flow__edge[data-id="edge-amesim_p4node2_1-port_2-amesim_pnl0001_13-port_2-1786972847258"] .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:project:demo-system",
|
|
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);
|
|
});
|