Files
SystemSimulationApp/frontend/tests/e2e/component-symbols.spec.ts
T

1248 lines
41 KiB
TypeScript

import { expect, test, type Locator, type Page } from "@playwright/test";
type SymbolCase = {
category: string;
domain: "medium" | "pneumatic" | "signal" | "mechanical";
modelType: string;
};
const symbolCases: SymbolCase[] = [
{ category: "media", domain: "medium", modelType: "amesim_ideal_air_medium" },
{ category: "media", domain: "medium", modelType: "amesim_helium_medium" },
{ category: "storage", domain: "pneumatic", modelType: "amesim_pnch023" },
{ category: "storage", domain: "pneumatic", modelType: "amesim_pnch012" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnor001" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnvo001_fixed" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnvo001" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnl00r" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnl0001" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnl0002" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnl0003" },
{ category: "junctions", domain: "pneumatic", modelType: "amesim_pn3node2" },
{ category: "junctions", domain: "pneumatic", modelType: "amesim_p4node2" },
{ category: "boundary", domain: "pneumatic", modelType: "amesim_pnpl01" },
{ category: "signals", domain: "signal", modelType: "amesim_step0" },
{ category: "signals", domain: "signal", modelType: "amesim_ud00" },
{ category: "mechanical", domain: "mechanical", modelType: "amesim_f000" },
{ category: "mechanical", domain: "mechanical", modelType: "amesim_forc" },
{ category: "mechanical", domain: "mechanical", modelType: "amesim_mecmas21" },
{ category: "mechanical", domain: "mechanical", modelType: "amesim_lstp00a" },
{ category: "mechanical", domain: "mechanical", modelType: "amesim_lmechn1" },
{ category: "mechanical", domain: "mechanical", modelType: "amesim_pnrp17" },
];
function physicalPort(
name: string,
side: "left" | "right",
order: number,
) {
return {
name,
kind: "physical",
domain: "pneumatic",
nominalRole: "bidirectional",
positiveFlowDirection: "intoComponent",
side,
order,
};
}
function signalPort(
name: string,
side: "left" | "right",
nominalRole: "input" | "output",
order: number,
) {
return {
name,
kind: "signal",
domain: "signal",
nominalRole,
side,
order,
};
}
function mechanicalPort(
name: string,
side: "left" | "right",
order: number,
) {
return {
name,
kind: "physical",
domain: "mechanical",
nominalRole: "bidirectional",
positiveFlowDirection: "intoComponent",
side,
order,
};
}
function portsForSymbol(modelType: string) {
if (modelType === "amesim_pnor001") {
return [
physicalPort("port_1", "left", 10),
physicalPort("port_2", "right", 20),
];
}
if (modelType === "amesim_pnvo001") {
return [
signalPort("res", "left", "input", 5),
physicalPort("port_2", "left", 10),
physicalPort("port_3", "right", 20),
];
}
if (modelType === "amesim_step0") {
return [signalPort("out", "right", "output", 10)];
}
if (modelType === "amesim_mecmas21") {
return [
mechanicalPort("port_2", "left", 10),
mechanicalPort("port_1", "right", 20),
];
}
if (modelType === "amesim_lmechn1") {
return [
...Array.from({ length: 8 }, (_, index) =>
mechanicalPort(`port_${index + 1}`, "left", (index + 1) * 10),
),
mechanicalPort("port_9", "right", 90),
];
}
if (modelType === "amesim_pnrp17") {
return [
physicalPort("port_1", "left", 10),
mechanicalPort("port_3", "left", 20),
mechanicalPort("port_4", "left", 30),
mechanicalPort("port_2", "right", 40),
mechanicalPort("port_5", "right", 50),
];
}
return [];
}
async function dragSymbolToCanvas(
page: Page,
modelType: string,
targetPosition: { x: number; y: number },
) {
const pane = page.locator(".flow-canvas .react-flow__pane");
const paletteItem = page
.locator(".palette-item")
.filter({
has: page.locator(`[data-component-symbol="${modelType}"]`),
})
.first();
await paletteItem.dragTo(pane, { targetPosition });
const node = page
.locator(
`.flow-canvas .react-flow__node[data-id^="${modelType}_"]`,
)
.last();
await expect(node).toBeVisible();
const nodeId = await node.getAttribute("data-id");
expect(nodeId).not.toBeNull();
return page.locator(
`.flow-canvas .react-flow__node[data-id="${nodeId}"]`,
);
}
async function expectSymbolAnchors(
node: Locator,
anchors: Array<{ portName: string; x: number; y: number }>,
) {
for (const anchor of anchors) {
const error = await node.evaluate((element, expected) => {
const svg = element.querySelector<SVGSVGElement>(".component-symbol-svg");
const handle = element.querySelector<HTMLElement>(
`.port-handle[data-port-name="${expected.portName}"]`,
);
const matrix = svg?.getScreenCTM();
if (!svg || !handle || !matrix) {
return Number.POSITIVE_INFINITY;
}
const point = svg.createSVGPoint();
point.x = expected.x;
point.y = expected.y;
const screenPoint = point.matrixTransform(matrix);
const handleBounds = handle.getBoundingClientRect();
return Math.hypot(
screenPoint.x - (handleBounds.left + handleBounds.width / 2),
screenPoint.y - (handleBounds.top + handleBounds.height / 2),
);
}, anchor);
expect(error).toBeLessThan(2.5);
}
}
async function expectPortIndexLabels(
node: Locator,
expected: Record<string, string>,
) {
const labels = node.locator(".port-index-label");
await expect(labels).toHaveCount(Object.keys(expected).length);
for (const [portName, text] of Object.entries(expected)) {
const label = node.locator(
`.port-index-label[data-port-name="${portName}"]`,
);
await expect(label).toHaveCount(1);
await expect(label).toHaveText(text);
await expect(label).toHaveAttribute("data-port-index", text);
}
const firstLabel = labels.first();
await expect(firstLabel).toHaveCSS("font-size", "18px");
await expect(firstLabel).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(firstLabel).toHaveCSS("border-top-style", "none");
await expect(firstLabel).toHaveCSS("box-shadow", "none");
await expect(firstLabel).toHaveCSS("pointer-events", "none");
}
async function portIndexLabelLayout(node: Locator) {
return node.evaluate((element) => {
const labels = Array.from(
element.querySelectorAll<HTMLElement>(".port-index-label"),
).map((label) => {
const portName = label.dataset.portName ?? "";
const handle = element.querySelector<HTMLElement>(
`.port-handle[data-port-name="${portName}"]`,
);
const bounds = label.getBoundingClientRect();
const handleBounds = handle?.getBoundingClientRect();
const labelCenter = {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
};
const handleCenter = handleBounds
? {
x: handleBounds.left + handleBounds.width / 2,
y: handleBounds.top + handleBounds.height / 2,
}
: null;
const outward =
handle && handleCenter
? handle.classList.contains("react-flow__handle-left")
? labelCenter.x < handleCenter.x
: handle.classList.contains("react-flow__handle-right")
? labelCenter.x > handleCenter.x
: handle.classList.contains("react-flow__handle-top")
? labelCenter.y < handleCenter.y
: labelCenter.y > handleCenter.y
: false;
return {
bounds: {
bottom: bounds.bottom,
left: bounds.left,
right: bounds.right,
top: bounds.top,
},
distance: handleBounds
? Math.hypot(
labelCenter.x - handleCenter!.x,
labelCenter.y - handleCenter!.y,
)
: Number.POSITIVE_INFINITY,
outward,
};
});
let overlaps = 0;
for (let firstIndex = 0; firstIndex < labels.length; firstIndex += 1) {
for (
let secondIndex = firstIndex + 1;
secondIndex < labels.length;
secondIndex += 1
) {
const first = labels[firstIndex].bounds;
const second = labels[secondIndex].bounds;
if (
Math.min(first.right, second.right) >
Math.max(first.left, second.left) &&
Math.min(first.bottom, second.bottom) >
Math.max(first.top, second.top)
) {
overlaps += 1;
}
}
}
return {
maximumDistance: Math.max(...labels.map((label) => label.distance)),
minimumDistance: Math.min(...labels.map((label) => label.distance)),
overlaps,
outwardViolations: labels.filter((label) => !label.outward).length,
};
});
}
async function locatorCenter(locator: Locator) {
const bounds = await locator.boundingBox();
expect(bounds).not.toBeNull();
return {
x: bounds!.x + bounds!.width / 2,
y: bounds!.y + bounds!.height / 2,
};
}
async function distanceBetween(first: Locator, second: Locator) {
const [firstCenter, secondCenter] = await Promise.all([
locatorCenter(first),
locatorCenter(second),
]);
return Math.hypot(
secondCenter.x - firstCenter.x,
secondCenter.y - firstCenter.y,
);
}
async function dragNodePortNearTarget(
page: Page,
movingNode: Locator,
movingPort: Locator,
targetPort: Locator,
remainingGap = 8,
) {
const [dragStart, movingPortCenter, targetPortCenter] = await Promise.all([
locatorCenter(movingNode.locator(".node-symbol")),
locatorCenter(movingPort),
locatorCenter(targetPort),
]);
const deltaX = targetPortCenter.x - movingPortCenter.x;
const deltaY = targetPortCenter.y - movingPortCenter.y;
const distance = Math.hypot(deltaX, deltaY);
expect(distance).toBeGreaterThan(remainingGap);
const movementRatio = (distance - remainingGap) / distance;
await page.mouse.move(dragStart.x, dragStart.y);
await page.mouse.down();
let pointerX = dragStart.x + deltaX * movementRatio;
let pointerY = dragStart.y + deltaY * movementRatio;
await page.mouse.move(pointerX, pointerY, { steps: 12 });
// 旋转会改变节点外框尺寸,React Flow 可能同时刷新 viewport;根据实际
// Handle 中心再校正两次,确保测试模拟的是用户真正把端口拖到附近。
for (let attempt = 0; attempt < 2; attempt += 1) {
const [actualMovingCenter, actualTargetCenter] = await Promise.all([
locatorCenter(movingPort),
locatorCenter(targetPort),
]);
const correctionX = actualTargetCenter.x - actualMovingCenter.x;
const correctionY = actualTargetCenter.y - actualMovingCenter.y;
const actualDistance = Math.hypot(correctionX, correctionY);
if (actualDistance <= remainingGap + 1) {
break;
}
const correctionRatio = (actualDistance - remainingGap) / actualDistance;
pointerX += correctionX * correctionRatio;
pointerY += correctionY * correctionRatio;
await page.mouse.move(pointerX, pointerY, { steps: 4 });
}
}
const categoryLabels: Record<string, string> = {
boundary: "边界元件",
flow: "流动元件",
junctions: "连接元件",
mechanical: "机械元件",
media: "介质物性",
signals: "信号元件",
storage: "储能元件",
};
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
const preparedKey = "system-simulation-flow:component-symbols-prepared";
if (window.sessionStorage.getItem(preparedKey) !== "1") {
window.localStorage.clear();
window.sessionStorage.clear();
window.sessionStorage.setItem(preparedKey, "1");
}
window.location.hash = "#/modeling";
});
await page.route("**/api/components/catalog", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
schemaVersion: 1,
libraries: [
{
id: "amesim",
label: "AMESim 组件库",
version: "0.2.0",
sourcePackage: "tests.e2e.amesim-symbols",
temporary: true,
order: 1,
components: symbolCases.map((item, index) => ({
type: item.modelType,
label: item.modelType,
modelType: item.modelType,
modelVersion: "0.1.0",
symbol: item.modelType,
order: index + 1,
category: {
id: item.category,
label: categoryLabels[item.category],
order: Object.keys(categoryLabels).indexOf(item.category) + 1,
},
ports: portsForSymbol(item.modelType),
parameters:
item.modelType === "amesim_mecmas21"
? [
{
name: "useFriction",
label: "启用摩擦",
quantity: "dimensionless",
unit: "",
default: 1,
minimum: 1,
maximum: 2,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "否" },
{ value: 2, label: "是" },
],
},
{
name: "stoptype",
label: "限位类型",
quantity: "dimensionless",
unit: "",
default: 4,
minimum: 1,
maximum: 4,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "理想限位" },
{ value: 2, label: "弹性限位" },
{ value: 3, label: "恢复碰撞" },
{ value: 4, label: "无限位" },
],
},
]
: [],
})),
},
],
}),
});
});
});
test("全部已移植 AMESim 模型使用专属图标及正确领域配色", async ({ page }) => {
await page.goto("/");
const palette = page.locator("aside.palette");
await expect(palette.locator('[data-component-symbol^="amesim_"]')).toHaveCount(
symbolCases.length,
);
await expect(palette.locator('[data-component-symbol="generic"]')).toHaveCount(0);
for (const item of symbolCases) {
const symbol = palette.locator(`[data-component-symbol="${item.modelType}"]`);
await expect(symbol).toHaveCount(1);
await expect(symbol.locator("svg")).toHaveAttribute("data-symbol-domain", item.domain);
await expect(symbol).toHaveAttribute(
"data-symbol-presentation",
item.domain === "medium" ? "bare" : "framed",
);
}
});
test("MECMAS21 图标按摩擦和限位参数切换四种参考外观", async ({ page }) => {
await page.goto("/");
const node = await dragSymbolToCanvas(page, "amesim_mecmas21", {
x: 430,
y: 300,
});
const symbol = node.locator(
'[data-component-symbol="amesim_mecmas21"]',
);
const state = symbol.locator("[data-mecmas-variant]");
const core = symbol.locator('[data-mecmas-part="core"]');
const envelope = node.locator('[data-symbol-envelope="true"]');
const useFriction = page.getByLabel("启用摩擦", { exact: true });
const stoptype = page.getByLabel("限位类型", { exact: true });
const envelopeHeight = async () =>
(await envelope.boundingBox())?.height ?? 0;
await expectSymbolAnchors(node, [
{ portName: "port_2", x: 2, y: 24 },
{ portName: "port_1", x: 62, y: 24 },
]);
const expectVariant = async ({
friction,
guides,
limits,
lowerGuide,
variant,
}: {
friction: "disabled" | "enabled";
guides: boolean;
limits: "finite" | "unlimited";
lowerGuide: "finite" | "none" | "unlimited";
variant: string;
}) => {
await expect(state).toHaveAttribute("data-mecmas-variant", variant);
await expect(state).toHaveAttribute("data-mecmas-friction", friction);
await expect(state).toHaveAttribute("data-mecmas-limits", limits);
await expect(
symbol.locator('[data-mecmas-part="friction-markers"]'),
).toHaveCount(friction === "enabled" ? 1 : 0);
await expect(symbol.locator('[data-mecmas-part="guides"]')).toHaveCount(
guides ? 1 : 0,
);
await expect(
symbol.locator('[data-mecmas-part="finite-lower-guide"]'),
).toHaveCount(lowerGuide === "finite" ? 1 : 0);
await expect(
symbol.locator('[data-mecmas-part="unlimited-lower-guide"]'),
).toHaveCount(lowerGuide === "unlimited" ? 1 : 0);
await expect(symbol.locator("svg")).toHaveAttribute("viewBox", "0 0 64 48");
};
await expectVariant({
friction: "disabled",
guides: false,
limits: "unlimited",
lowerGuide: "none",
variant: "plain-unlimited",
});
await expect.poll(envelopeHeight).toBeLessThan(60);
const fixedCore = await core.evaluate((element: SVGGElement) => {
const bounds = element.getBBox();
return {
bounds: {
height: bounds.height,
width: bounds.width,
x: bounds.x,
y: bounds.y,
},
markup: element.innerHTML,
};
});
const compactEnvelopeHeight = await envelopeHeight();
await useFriction.selectOption("2");
await expectVariant({
friction: "enabled",
guides: true,
limits: "unlimited",
lowerGuide: "unlimited",
variant: "friction-unlimited",
});
await expect.poll(envelopeHeight).toBeGreaterThan(compactEnvelopeHeight + 20);
await stoptype.selectOption("2");
await expectVariant({
friction: "enabled",
guides: true,
limits: "finite",
lowerGuide: "finite",
variant: "friction-limited",
});
await useFriction.selectOption("1");
await expectVariant({
friction: "disabled",
guides: true,
limits: "finite",
lowerGuide: "finite",
variant: "plain-limited",
});
await stoptype.selectOption("4");
await expectVariant({
friction: "disabled",
guides: false,
limits: "unlimited",
lowerGuide: "none",
variant: "plain-unlimited",
});
await expect.poll(envelopeHeight).toBeLessThan(compactEnvelopeHeight + 1);
expect(await envelopeHeight()).toBeCloseTo(compactEnvelopeHeight, 1);
expect(
await core.evaluate((element: SVGGElement) => {
const bounds = element.getBBox();
return {
bounds: {
height: bounds.height,
width: bounds.width,
x: bounds.x,
y: bounds.y,
},
markup: element.innerHTML,
};
}),
).toEqual(fixedCore);
});
test("PNRP17 图标匹配参考结构并将五个端口放在对应接口", async ({ page }) => {
await page.goto("/");
const paletteSymbol = page.locator(
'aside.palette [data-component-symbol="amesim_pnrp17"]',
);
await expect(paletteSymbol).toHaveCount(1);
await expect(
paletteSymbol.locator('[stroke="#00af00"], [fill="#00af00"]'),
).toHaveCount(0);
const piston = await dragSymbolToCanvas(page, "amesim_pnrp17", {
x: 430,
y: 300,
});
await expectSymbolAnchors(piston, [
{ portName: "port_1", x: 25.5, y: 48 },
{ portName: "port_3", x: 2, y: 4.4 },
{ portName: "port_4", x: 2, y: 24.1 },
{ portName: "port_2", x: 62, y: 4.4 },
{ portName: "port_5", x: 62, y: 24.1 },
]);
await expect(
piston.locator('.port-handle[data-port-name="port_1"]'),
).toHaveClass(/react-flow__handle-bottom/);
});
test("选中元件时按接口编号显示固定且不重叠的数字标记", async ({
page,
}) => {
await page.goto("/");
const mecmas = await dragSymbolToCanvas(page, "amesim_mecmas21", {
x: 260,
y: 220,
});
await expectPortIndexLabels(mecmas, { port_1: "1", port_2: "2" });
let layout = await portIndexLabelLayout(mecmas);
expect(layout.overlaps).toBe(0);
expect(layout.outwardViolations).toBe(0);
expect(layout.minimumDistance).toBeGreaterThan(18);
expect(layout.maximumDistance).toBeLessThan(52);
const signalOrifice = await dragSymbolToCanvas(page, "amesim_pnvo001", {
x: 520,
y: 220,
});
await expect(mecmas.locator(".port-index-label")).toHaveCount(0);
await expectPortIndexLabels(signalOrifice, {
res: "1",
port_2: "2",
port_3: "3",
});
layout = await portIndexLabelLayout(signalOrifice);
expect(layout.overlaps).toBe(0);
expect(layout.outwardViolations).toBe(0);
expect(layout.maximumDistance).toBeLessThan(52);
const multiPortNode = await dragSymbolToCanvas(page, "amesim_lmechn1", {
x: 780,
y: 300,
});
await expect(signalOrifice.locator(".port-index-label")).toHaveCount(0);
await expectPortIndexLabels(
multiPortNode,
Object.fromEntries(
Array.from({ length: 9 }, (_, index) => [
`port_${index + 1}`,
String(index + 1),
]),
),
);
layout = await portIndexLabelLayout(multiPortNode);
expect(layout.overlaps).toBe(0);
expect(layout.outwardViolations).toBe(0);
expect(layout.minimumDistance).toBeGreaterThan(18);
expect(layout.maximumDistance).toBeLessThan(52);
await page
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
.click();
await page
.getByRole("button", { name: "水平镜像 (Shift+H)", exact: true })
.click();
await expectPortIndexLabels(
multiPortNode,
Object.fromEntries(
Array.from({ length: 9 }, (_, index) => [
`port_${index + 1}`,
String(index + 1),
]),
),
);
layout = await portIndexLabelLayout(multiPortNode);
expect(layout.overlaps).toBe(0);
expect(layout.outwardViolations).toBe(0);
expect(layout.maximumDistance).toBeLessThan(52);
const outsideLabelBounds = await multiPortNode
.locator('.port-index-label[data-port-name="port_9"]')
.boundingBox();
expect(outsideLabelBounds).not.toBeNull();
await page.mouse.click(
outsideLabelBounds!.x + outsideLabelBounds!.width / 2,
outsideLabelBounds!.y + outsideLabelBounds!.height / 2,
);
await expect(multiPortNode).not.toHaveClass(/selected/);
await expect(multiPortNode.locator(".port-index-label")).toHaveCount(0);
});
test("AMESim canvas nodes use icon anchors and highlight only compatible free ports", async ({
page,
}) => {
await page.goto("/");
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 250,
y: 210,
});
const secondOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 610,
y: 210,
});
const stepSource = await dragSymbolToCanvas(page, "amesim_step0", {
x: 250,
y: 450,
});
const signalOrifice = await dragSymbolToCanvas(page, "amesim_pnvo001", {
x: 610,
y: 450,
});
await expect(firstOrifice.locator(".sim-node")).toHaveClass(
/component-icon-only/,
);
await expect(firstOrifice.locator(".node-label")).toBeHidden();
const frameStyle = await firstOrifice.locator(".sim-node-frame").evaluate(
(element) => ({
borderWidth: getComputedStyle(element).borderTopWidth,
background: getComputedStyle(element).backgroundColor,
}),
);
expect(frameStyle).toEqual({
borderWidth: "0px",
background: "rgba(0, 0, 0, 0)",
});
const firstOrificeAnchors = [
{ portName: "port_1", x: 2, y: 24 },
{ portName: "port_2", x: 62, y: 24 },
];
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
await expectSymbolAnchors(signalOrifice, [
{ portName: "res", x: 26, y: 46 },
{ portName: "port_2", x: 2, y: 24 },
{ portName: "port_3", x: 62, y: 24 },
]);
await firstOrifice.locator(".node-symbol").click({ position: { x: 56, y: 42 } });
await page
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
.click();
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
await page
.getByRole("button", { name: "水平镜像 (Shift+H)", exact: true })
.click();
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
const firstPhysicalOutput = firstOrifice.locator(
'.port-handle[data-port-name="port_2"]',
);
const sameNodePort = firstOrifice.locator(
'.port-handle[data-port-name="port_1"]',
);
const compatiblePhysicalPort = secondOrifice.locator(
'.port-handle[data-port-name="port_1"]',
);
const signalOutput = stepSource.locator(
'.port-handle[data-port-name="out"]',
);
const signalInput = signalOrifice.locator(
'.port-handle[data-port-name="res"]',
);
await expect(signalInput).toHaveClass(/react-flow__handle-bottom/);
const pneumaticInput = signalOrifice.locator(
'.port-handle[data-port-name="port_2"]',
);
await firstPhysicalOutput.click();
await expect(firstPhysicalOutput).toHaveAttribute(
"data-connection-state",
"origin",
);
await expect(compatiblePhysicalPort).toHaveAttribute(
"data-connection-state",
"compatible",
);
await expect(pneumaticInput).toHaveAttribute(
"data-connection-state",
"compatible",
);
await expect(sameNodePort).toHaveAttribute(
"data-connection-state",
"incompatible",
);
await expect(signalInput).toHaveAttribute(
"data-connection-state",
"incompatible",
);
await expect(sameNodePort).toHaveCSS("pointer-events", "none");
await expect(signalInput).toHaveCSS("pointer-events", "none");
await page.locator(".flow-canvas .react-flow__pane").click({
position: { x: 20, y: 20 },
});
await expect(firstPhysicalOutput).toHaveAttribute(
"data-connection-state",
"idle",
);
await compatiblePhysicalPort.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(compatiblePhysicalPort).toHaveAttribute(
"data-connection-state",
"origin",
);
await page.keyboard.press("Escape");
await expect(compatiblePhysicalPort).toHaveAttribute(
"data-connection-state",
"idle",
);
await firstPhysicalOutput.click();
await compatiblePhysicalPort.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true");
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
await expect(firstPhysicalOutput).toHaveCSS("opacity", "0");
await expect(compatiblePhysicalPort).toHaveCSS("opacity", "0");
await expect(firstPhysicalOutput).toHaveCSS("pointer-events", "none");
await expect(compatiblePhysicalPort).toHaveCSS("pointer-events", "none");
const physicalEdge = page.locator(".flow-canvas .react-flow__edge").first();
const edgeClickPoint = await physicalEdge
.locator(".react-flow__edge-interaction")
.evaluate((path: SVGPathElement) => {
const point = path.getPointAtLength(path.getTotalLength() / 2);
const matrix = path.getScreenCTM();
if (!matrix) {
throw new Error("Unable to resolve the edge screen position");
}
const screenPoint = new DOMPoint(point.x, point.y).matrixTransform(matrix);
return { x: screenPoint.x, y: screenPoint.y };
});
await page.mouse.click(edgeClickPoint.x, edgeClickPoint.y);
await expect(physicalEdge).toHaveClass(/selected|editor-edge-selected/);
await page.getByRole("button", { name: "删除 (Delete)", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "false");
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "false");
await expect(firstPhysicalOutput).toHaveCSS("opacity", "1");
await expect(compatiblePhysicalPort).toHaveCSS("opacity", "1");
await expect(firstPhysicalOutput).not.toHaveCSS("pointer-events", "none");
await expect(compatiblePhysicalPort).not.toHaveCSS("pointer-events", "none");
await firstPhysicalOutput.click();
await compatiblePhysicalPort.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
const signalOutputBounds = await signalOutput.boundingBox();
const signalInputBounds = await signalInput.boundingBox();
expect(signalOutputBounds).not.toBeNull();
expect(signalInputBounds).not.toBeNull();
await page.mouse.move(
signalOutputBounds!.x + signalOutputBounds!.width / 2,
signalOutputBounds!.y + signalOutputBounds!.height / 2,
);
await page.mouse.down();
await expect(signalOutput).toHaveAttribute(
"data-connection-state",
"origin",
);
await expect(signalInput).toHaveAttribute(
"data-connection-state",
"compatible",
);
const paneBounds = await page
.locator(".flow-canvas .react-flow__pane")
.boundingBox();
expect(paneBounds).not.toBeNull();
await page.mouse.move(paneBounds!.x + 20, paneBounds!.y + 20, { steps: 4 });
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__connection")).toHaveCount(0);
await expect(signalOutput).toHaveAttribute(
"data-connection-state",
"idle",
);
await expect(signalInput).toHaveAttribute(
"data-connection-state",
"idle",
);
await expect(pneumaticInput).toHaveAttribute(
"data-connection-state",
"idle",
);
const retriedSignalOutputBounds = await signalOutput.boundingBox();
const retriedSignalInputBounds = await signalInput.boundingBox();
expect(retriedSignalOutputBounds).not.toBeNull();
expect(retriedSignalInputBounds).not.toBeNull();
await page.mouse.move(
retriedSignalOutputBounds!.x + retriedSignalOutputBounds!.width / 2,
retriedSignalOutputBounds!.y + retriedSignalOutputBounds!.height / 2,
);
await page.mouse.down();
await expect(signalOutput).toHaveAttribute(
"data-connection-state",
"origin",
);
await page.mouse.move(
retriedSignalOutputBounds!.x + retriedSignalOutputBounds!.width / 2 + 12,
retriedSignalOutputBounds!.y + retriedSignalOutputBounds!.height / 2,
);
await expect(signalInput).toHaveAttribute(
"data-connection-state",
"compatible",
);
await expect(pneumaticInput).toHaveAttribute(
"data-connection-state",
"incompatible",
);
await page.mouse.move(
retriedSignalInputBounds!.x + retriedSignalInputBounds!.width / 2,
retriedSignalInputBounds!.y + retriedSignalInputBounds!.height / 2,
{ steps: 5 },
);
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
await expect(signalOutput).toHaveAttribute("data-connected", "true");
await expect(signalInput).toHaveAttribute("data-connected", "true");
});
test("兼容接口接近时吸附为无连线接触连接,拖开后自动断开", async ({
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 280,
y: 300,
});
const secondOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 650,
y: 300,
});
const firstPort = firstOrifice.locator(
'.port-handle[data-port-name="port_2"]',
);
const secondPort = secondOrifice.locator(
'.port-handle[data-port-name="port_1"]',
);
await dragNodePortNearTarget(
page,
secondOrifice,
secondPort,
firstPort,
16,
);
await expect(firstPort).toHaveAttribute("data-connection-state", "idle");
await expect(secondPort).toHaveAttribute("data-connection-state", "idle");
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(14);
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await dragNodePortNearTarget(
page,
secondOrifice,
secondPort,
firstPort,
);
await expect(firstPort).toHaveAttribute(
"data-connection-state",
"compatible",
);
await expect(secondPort).toHaveAttribute(
"data-connection-state",
"origin",
);
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(4);
await page.mouse.up();
await expect
.poll(() => distanceBetween(firstPort, secondPort))
.toBeLessThan(2);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
const contactHit = page.locator(".flow-canvas .contact-edge-hit");
await expect(contactHit).toHaveCount(1);
await expect(firstPort).toHaveAttribute("data-connected", "true");
await expect(secondPort).toHaveAttribute("data-connected", "true");
await expect(firstPort).toHaveCSS("opacity", "0");
await expect(secondPort).toHaveCSS("opacity", "0");
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
const xmlBlock = page.getByLabel("生成的系统 XML");
await expect(xmlBlock).toContainText('<Connection id="edge-contact-');
await expect(xmlBlock).toContainText('component="amesim_pnor001_1" port="port_2"');
await expect(xmlBlock).toContainText('component="amesim_pnor001_2" port="port_1"');
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const savedEdge = await page.evaluate(() => {
const raw = window.localStorage.getItem(
"system-simulation-flow:project:demo-system",
);
const project = raw ? JSON.parse(raw) : null;
return project?.edges?.[0] ?? null;
});
expect(savedEdge).toMatchObject({
source: "amesim_pnor001_2",
sourceHandle: "port_1",
target: "amesim_pnor001_1",
targetHandle: "port_2",
});
await contactHit.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveClass(
/editor-edge-selected/,
);
await page.getByRole("button", { name: "删除 (Delete)", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(firstPort).toHaveAttribute("data-connected", "false");
await expect(secondPort).toHaveAttribute("data-connected", "false");
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(8);
await dragNodePortNearTarget(
page,
secondOrifice,
secondPort,
firstPort,
5,
);
await expect(firstPort).toHaveAttribute(
"data-connection-state",
"compatible",
);
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
await page
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await page.getByRole("button", { name: "撤销 (Ctrl+Z)", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
const secondSymbolCenter = await locatorCenter(
secondOrifice.locator(".node-symbol"),
);
await page.mouse.move(secondSymbolCenter.x, secondSymbolCenter.y);
await page.mouse.down();
await page.mouse.move(secondSymbolCenter.x + 90, secondSymbolCenter.y + 35, {
steps: 10,
});
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(firstPort).toHaveAttribute("data-connected", "false");
await expect(secondPort).toHaveAttribute("data-connected", "false");
await expect(firstPort).toHaveCSS("opacity", "1");
await expect(secondPort).toHaveCSS("opacity", "1");
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(50);
});
test("不同类型接口接近时不会吸附或建立逻辑连接", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const orifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 280,
y: 320,
});
const stepSource = await dragSymbolToCanvas(page, "amesim_step0", {
x: 650,
y: 320,
});
const physicalPort = orifice.locator(
'.port-handle[data-port-name="port_2"]',
);
const signalPort = stepSource.locator(
'.port-handle[data-port-name="out"]',
);
await dragNodePortNearTarget(
page,
stepSource,
signalPort,
physicalPort,
);
await expect(physicalPort).not.toHaveAttribute(
"data-connection-state",
"compatible",
);
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
expect(await distanceBetween(physicalPort, signalPort)).toBeGreaterThan(4);
await expect(physicalPort).toHaveAttribute("data-connected", "false");
await expect(signalPort).toHaveAttribute("data-connected", "false");
});
test("旋转后的接口可以与方向不同的兼容接口接触连接", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const horizontalOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 300,
y: 300,
});
const rotatedOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 650,
y: 500,
});
await rotatedOrifice.locator(".node-symbol").click();
await page
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
.click();
const horizontalPort = horizontalOrifice.locator(
'.port-handle[data-port-name="port_2"]',
);
const rotatedPort = rotatedOrifice.locator(
'.port-handle[data-port-name="port_1"]',
);
await dragNodePortNearTarget(
page,
rotatedOrifice,
rotatedPort,
horizontalPort,
);
await expect(horizontalPort).toHaveAttribute(
"data-connection-state",
"compatible",
);
await expect(rotatedPort).toHaveAttribute(
"data-connection-state",
"origin",
);
await page.mouse.up();
await expect
.poll(() => distanceBetween(horizontalPort, rotatedPort))
.toBeLessThan(2);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
await expect(horizontalPort).toHaveAttribute("data-connected", "true");
await expect(rotatedPort).toHaveAttribute("data-connected", "true");
});
test("旧工程保存的通用图标会按当前目录定义升级", async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
window.localStorage.setItem(
"system-simulation-flow:autosave",
JSON.stringify({
name: "legacy-symbol-project",
nodes: [
{
id: "amesim_pnl0001_1",
type: "simulationComponent",
position: { x: 200, y: 160 },
data: {
label: "amesim_pnl0001_1",
componentType: "amesim_pnl0001",
modelType: "amesim_pnl0001",
symbol: "pipe",
ports: [],
parameters: {},
parameterUnits: {},
rotation: 0,
mirrored: false,
},
},
],
edges: [],
simulation: {
t_start: 0,
t_stop: 1,
step: 0.1,
max_step: 0.05,
method: "BDF",
},
}),
);
});
await page.reload();
await page.getByRole("button", { name: "恢复", exact: true }).click();
const restoredNode = page.locator(
'.flow-canvas .react-flow__node[data-id="amesim_pnl0001_1"]',
);
await expect(restoredNode).toBeVisible();
await expect(
restoredNode.locator('[data-component-symbol="amesim_pnl0001"]'),
).toBeVisible();
await expect(restoredNode.locator('[data-component-symbol="pipe"]')).toHaveCount(0);
});
test("专用图标的选择框和鼠标命中范围贴合实际图形包络", async ({ page }) => {
await page.goto("/");
const helium = await dragSymbolToCanvas(page, "amesim_helium_medium", {
x: 430,
y: 280,
});
const simNode = helium.locator(".sim-node");
const envelope = helium.locator('[data-symbol-envelope="true"]');
const circle = helium.locator(".component-symbol-svg circle");
await expect(helium).toHaveClass(/component-icon-node/);
await expect(helium).toHaveCSS("pointer-events", "none");
await expect(simNode).toHaveCSS("pointer-events", "none");
await expect(envelope).toHaveCSS("outline-style", "dashed");
await expect(envelope).toHaveCSS("pointer-events", "auto");
await expect(simNode).toHaveCSS("outline-style", "none");
const [nodeBounds, envelopeBounds, circleBounds, paneBounds] =
await Promise.all([
helium.boundingBox(),
envelope.boundingBox(),
circle.boundingBox(),
page.locator(".flow-canvas .react-flow__pane").boundingBox(),
]);
expect(nodeBounds).not.toBeNull();
expect(envelopeBounds).not.toBeNull();
expect(circleBounds).not.toBeNull();
expect(paneBounds).not.toBeNull();
expect(envelopeBounds!.width).toBeLessThan(nodeBounds!.width * 0.7);
expect(
Math.abs(envelopeBounds!.width - envelopeBounds!.height),
).toBeLessThan(3);
expect(envelopeBounds!.width - circleBounds!.width).toBeGreaterThanOrEqual(0);
expect(envelopeBounds!.width - circleBounds!.width).toBeLessThanOrEqual(4);
await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8);
await expect(helium).not.toHaveClass(/selected/);
const oldTransparentMargin = {
x: nodeBounds!.x + 5,
y: envelopeBounds!.y + envelopeBounds!.height / 2,
};
expect(oldTransparentMargin.x).toBeLessThan(envelopeBounds!.x - 8);
await page.mouse.click(oldTransparentMargin.x, oldTransparentMargin.y);
await expect(helium).not.toHaveClass(/selected/);
await page.mouse.click(
envelopeBounds!.x + envelopeBounds!.width / 2,
envelopeBounds!.y + envelopeBounds!.height / 2,
);
await expect(helium).toHaveClass(/selected/);
await expect(envelope).toHaveCSS("outline-style", "dashed");
});