4503 lines
158 KiB
TypeScript
4503 lines
158 KiB
TypeScript
import { expect, test, type Locator, type Page } from "@playwright/test";
|
|
|
|
import { expandSimulationConsole } from "./fixtures";
|
|
|
|
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_pnpl01") {
|
|
return [physicalPort("port_1", "left", 10)];
|
|
}
|
|
if (modelType === "amesim_pnch023") {
|
|
return [
|
|
physicalPort("port_1", "left", 10),
|
|
physicalPort("port_2", "right", 20),
|
|
];
|
|
}
|
|
if (modelType === "amesim_pnch012") {
|
|
return [
|
|
physicalPort("port_1", "left", 10),
|
|
physicalPort("port_2", "right", 20),
|
|
physicalPort("port_3", "left", 30),
|
|
physicalPort("port_4", "right", 40),
|
|
];
|
|
}
|
|
if (modelType === "amesim_pnor001") {
|
|
return [
|
|
physicalPort("port_1", "left", 10),
|
|
physicalPort("port_2", "right", 20),
|
|
];
|
|
}
|
|
if (modelType === "amesim_pnvo001_fixed") {
|
|
return [
|
|
physicalPort("port_2", "left", 10),
|
|
physicalPort("port_3", "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_pnl00r" ||
|
|
modelType === "amesim_pnl0001" ||
|
|
modelType === "amesim_pnl0002" ||
|
|
modelType === "amesim_pnl0003"
|
|
) {
|
|
return [
|
|
physicalPort("port_1", "left", 10),
|
|
physicalPort("port_2", "right", 20),
|
|
];
|
|
}
|
|
if (modelType === "amesim_pn3node2") {
|
|
return [
|
|
physicalPort("port_1", "left", 10),
|
|
physicalPort("port_2", "right", 20),
|
|
physicalPort("port_3", "right", 30),
|
|
];
|
|
}
|
|
if (modelType === "amesim_p4node2") {
|
|
return [
|
|
physicalPort("port_1", "left", 10),
|
|
physicalPort("port_2", "right", 20),
|
|
physicalPort("port_3", "right", 30),
|
|
physicalPort("port_4", "right", 40),
|
|
];
|
|
}
|
|
if (modelType === "amesim_step0" || modelType === "amesim_ud00") {
|
|
return [signalPort("out", "right", "output", 10)];
|
|
}
|
|
if (modelType === "amesim_f000") {
|
|
return [mechanicalPort("port_1", "right", 10)];
|
|
}
|
|
if (modelType === "amesim_forc") {
|
|
return [
|
|
signalPort("res", "left", "input", 10),
|
|
mechanicalPort("port_2", "right", 20),
|
|
];
|
|
}
|
|
if (modelType === "amesim_mecmas21") {
|
|
return [
|
|
mechanicalPort("port_2", "left", 10),
|
|
mechanicalPort("port_1", "right", 20),
|
|
];
|
|
}
|
|
if (modelType === "amesim_lstp00a") {
|
|
return [
|
|
mechanicalPort("port_1", "left", 10),
|
|
mechanicalPort("port_2", "right", 20),
|
|
];
|
|
}
|
|
if (modelType === "amesim_lmechn1") {
|
|
return [
|
|
...Array.from({ length: 20 }, (_, index) =>
|
|
mechanicalPort(`port_${index + 1}`, "right", (index + 1) * 10),
|
|
),
|
|
mechanicalPort("port_21", "left", 210),
|
|
];
|
|
}
|
|
if (modelType === "amesim_pnrp17") {
|
|
return [
|
|
physicalPort("port_1", "left", 10),
|
|
mechanicalPort("port_3", "left", 20),
|
|
mechanicalPort("port_2", "left", 30),
|
|
mechanicalPort("port_4", "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 clickLocatorCenter(page: Page, locator: Locator) {
|
|
const center = await locatorCenter(locator);
|
|
await page.mouse.click(center.x, center.y);
|
|
}
|
|
|
|
async function expectNoActivePortConnection(page: Page) {
|
|
await expect(
|
|
page.locator(
|
|
'.port-handle[data-connection-state="origin"], .port-handle[data-connection-state="compatible"], .port-handle[data-connection-state="incompatible"]',
|
|
),
|
|
).toHaveCount(0);
|
|
await expect(page.locator(".flow-canvas .react-flow__connection")).toHaveCount(0);
|
|
}
|
|
|
|
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 = 4,
|
|
) {
|
|
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:
|
|
item.modelType === "amesim_mecmas21" ||
|
|
item.modelType === "amesim_lmechn1" ||
|
|
item.modelType === "amesim_forc"
|
|
? "0.2.0"
|
|
: "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: 2,
|
|
minimum: 1,
|
|
maximum: 2,
|
|
minimumExclusive: false,
|
|
editor: "choice",
|
|
options: [
|
|
{ value: 1, label: "否" },
|
|
{ value: 2, label: "是" },
|
|
],
|
|
},
|
|
{
|
|
name: "stoptype",
|
|
label: "限位类型",
|
|
quantity: "dimensionless",
|
|
unit: "",
|
|
default: 1,
|
|
minimum: 1,
|
|
maximum: 4,
|
|
minimumExclusive: false,
|
|
editor: "choice",
|
|
options: [
|
|
{ value: 1, label: "理想限位" },
|
|
{ value: 2, label: "弹性限位" },
|
|
{ value: 3, label: "恢复碰撞" },
|
|
{ value: 4, label: "无限位" },
|
|
],
|
|
},
|
|
]
|
|
: item.modelType === "amesim_forc"
|
|
? [
|
|
{
|
|
name: "direction",
|
|
label: "力方向",
|
|
description: "图标旋转和镜像不会改变该参数。",
|
|
quantity: "dimensionless",
|
|
unit: "",
|
|
default: 1,
|
|
minimumExclusive: false,
|
|
editor: "choice",
|
|
options: [
|
|
{ value: 1, label: "正向" },
|
|
{ value: -1, label: "反向" },
|
|
],
|
|
},
|
|
]
|
|
: item.modelType === "amesim_lmechn1"
|
|
? [
|
|
{
|
|
name: "v1",
|
|
label: "右侧端口数",
|
|
description: "设置工作区中显示的右侧机械端口数量,最多 20 个。",
|
|
quantity: "dimensionless",
|
|
unit: "",
|
|
default: 2,
|
|
minimum: 1,
|
|
maximum: 20,
|
|
minimumExclusive: false,
|
|
},
|
|
{
|
|
name: "sum",
|
|
label: "节点求和模式",
|
|
quantity: "dimensionless",
|
|
unit: "",
|
|
default: 1,
|
|
minimumExclusive: false,
|
|
editor: "choice",
|
|
options: [
|
|
{
|
|
value: 1,
|
|
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);
|
|
const svg = symbol.locator("svg");
|
|
await expect(svg).toHaveAttribute("data-symbol-domain", item.domain);
|
|
await expect(svg).toHaveAttribute(
|
|
"data-symbol-stroke-scaling",
|
|
"independent",
|
|
);
|
|
await expect(symbol).toHaveAttribute(
|
|
"data-symbol-presentation",
|
|
item.domain === "medium" ? "bare" : "framed",
|
|
);
|
|
const registeredViewBox = await symbol.getAttribute("data-symbol-view-box");
|
|
expect(registeredViewBox).not.toBeNull();
|
|
await expect(svg).toHaveAttribute(
|
|
"viewBox",
|
|
registeredViewBox!,
|
|
);
|
|
const strokeScaling = await svg.evaluate((element) => {
|
|
const graphics = Array.from(
|
|
element.querySelectorAll<SVGElement>(
|
|
"path, line, polyline, polygon, rect, circle, ellipse, text",
|
|
),
|
|
);
|
|
const strokedGraphics = graphics.filter(
|
|
(graphic) => getComputedStyle(graphic).stroke !== "none",
|
|
);
|
|
return {
|
|
count: strokedGraphics.length,
|
|
independent: strokedGraphics.every(
|
|
(graphic) =>
|
|
getComputedStyle(graphic).vectorEffect === "non-scaling-stroke",
|
|
),
|
|
};
|
|
});
|
|
expect(strokeScaling.count).toBeGreaterThan(0);
|
|
expect(strokeScaling.independent).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("组件库按尺寸档位显示不同缩略图占比且不影响工作区", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
const paletteCases = [
|
|
{ modelType: "amesim_pnpl01", occupancy: 0.45, tier: "compact" },
|
|
{ modelType: "amesim_f000", occupancy: 0.45, tier: "compact" },
|
|
{ modelType: "amesim_pnch023", occupancy: 0.75, tier: "small" },
|
|
{ modelType: "amesim_pnch012", occupancy: 0.75, tier: "small" },
|
|
{ modelType: "amesim_pnrp17", occupancy: 0.9, tier: "standard" },
|
|
{ modelType: "amesim_lmechn1", occupancy: 0.9, tier: "custom" },
|
|
] as const;
|
|
|
|
for (const item of paletteCases) {
|
|
const symbol = page.locator(
|
|
`aside.palette [data-component-symbol="${item.modelType}"]`,
|
|
);
|
|
await expect(symbol).toHaveAttribute("data-symbol-size-tier", item.tier);
|
|
await expect(symbol).toHaveAttribute(
|
|
"data-palette-occupancy",
|
|
String(item.occupancy),
|
|
);
|
|
const geometry = await symbol
|
|
.locator("svg")
|
|
.evaluate((element: SVGSVGElement) => {
|
|
const bounds = element.getBBox();
|
|
const viewBox = element.viewBox.baseVal;
|
|
return {
|
|
centerOffsetX:
|
|
bounds.x + bounds.width / 2 - (viewBox.x + viewBox.width / 2),
|
|
centerOffsetY:
|
|
bounds.y + bounds.height / 2 - (viewBox.y + viewBox.height / 2),
|
|
occupancy:
|
|
Math.max(bounds.width, bounds.height) /
|
|
Math.max(viewBox.width, viewBox.height),
|
|
};
|
|
});
|
|
expect(geometry.occupancy).toBeCloseTo(item.occupancy, 3);
|
|
expect(geometry.centerOffsetX).toBeCloseTo(0, 3);
|
|
expect(geometry.centerOffsetY).toBeCloseTo(0, 3);
|
|
}
|
|
|
|
const compactOccupancies = await page
|
|
.locator('aside.palette [data-symbol-size-tier="compact"]')
|
|
.evaluateAll((elements) =>
|
|
elements.map((element) => {
|
|
const svg = element.querySelector("svg") as SVGSVGElement | null;
|
|
if (!svg) {
|
|
return 0;
|
|
}
|
|
const bounds = svg.getBBox();
|
|
const viewBox = svg.viewBox.baseVal;
|
|
return (
|
|
Math.max(bounds.width, bounds.height) /
|
|
Math.max(viewBox.width, viewBox.height)
|
|
);
|
|
}),
|
|
);
|
|
expect(compactOccupancies.length).toBeGreaterThan(1);
|
|
for (const occupancy of compactOccupancies) {
|
|
expect(occupancy).toBeCloseTo(0.45, 3);
|
|
}
|
|
|
|
const canvasSymbol = await dragSymbolToCanvas(page, "amesim_pnrp17", {
|
|
x: 430,
|
|
y: 280,
|
|
});
|
|
const canvasRatio = await canvasSymbol
|
|
.locator('[data-component-symbol="amesim_pnrp17"]')
|
|
.evaluate((element) => {
|
|
const symbolBounds = element.getBoundingClientRect();
|
|
const slotBounds = element.parentElement!.getBoundingClientRect();
|
|
return {
|
|
height: symbolBounds.height / slotBounds.height,
|
|
width: symbolBounds.width / slotBounds.width,
|
|
};
|
|
});
|
|
await expect(
|
|
canvasSymbol.locator('[data-component-symbol="amesim_pnrp17"]'),
|
|
).not.toHaveAttribute("data-palette-occupancy", /.+/);
|
|
expect(canvasRatio.width).toBeCloseTo(1, 2);
|
|
expect(canvasRatio.height).toBeCloseTo(1, 2);
|
|
});
|
|
|
|
test("介质物性组件统一使用 standard 画布、60% 图标占比和洋红文字", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const cases = [
|
|
{
|
|
label: "Air",
|
|
modelType: "amesim_ideal_air_medium",
|
|
target: { x: 300, y: 260 },
|
|
},
|
|
{
|
|
label: "He",
|
|
modelType: "amesim_helium_medium",
|
|
target: { x: 560, y: 260 },
|
|
},
|
|
] as const;
|
|
|
|
for (const item of cases) {
|
|
const paletteSymbol = page.locator(
|
|
`aside.palette [data-component-symbol="${item.modelType}"]`,
|
|
);
|
|
await expect(paletteSymbol).toHaveAttribute(
|
|
"data-symbol-size-tier",
|
|
"standard",
|
|
);
|
|
|
|
const node = await dragSymbolToCanvas(page, item.modelType, item.target);
|
|
const simNode = node.locator(".sim-node");
|
|
const symbol = node.locator(
|
|
`[data-component-symbol="${item.modelType}"]`,
|
|
);
|
|
const svg = symbol.locator("svg");
|
|
const circle = svg.locator("circle");
|
|
const label = svg.locator(`[data-symbol-label="${item.label}"]`);
|
|
|
|
await expect(simNode).toHaveAttribute("data-node-layout-tier", "standard");
|
|
await expect(simNode).toHaveAttribute("data-node-layout-width", "112");
|
|
await expect(simNode).toHaveAttribute("data-node-layout-height", "112");
|
|
await expect(symbol).toHaveAttribute("data-symbol-width", "112");
|
|
await expect(symbol).toHaveAttribute("data-symbol-height", "112");
|
|
await expect(svg).toHaveAttribute("viewBox", "0 -8 64 64");
|
|
await expect(circle).toHaveAttribute("r", "19.2");
|
|
await expect(circle).toHaveAttribute("stroke", "#8b134f");
|
|
await expect(circle).toHaveCSS("stroke", "rgb(139, 19, 79)");
|
|
await expect(label).toHaveAttribute("font-size", "13");
|
|
await expect(label).toHaveAttribute("font-weight", "600");
|
|
await expect(label).toHaveAttribute("fill", "#8b134f");
|
|
await expect(label).toHaveCSS("font-size", "13px");
|
|
await expect(label).toHaveCSS("font-weight", "600");
|
|
await expect(label).toHaveCSS("fill", "rgb(139, 19, 79)");
|
|
|
|
const occupancy = await svg.evaluate((element: SVGSVGElement) => {
|
|
const outline = element.querySelector("circle");
|
|
if (!outline) {
|
|
return 0;
|
|
}
|
|
const bounds = outline.getBBox();
|
|
const viewBox = element.viewBox.baseVal;
|
|
return Math.max(bounds.width, bounds.height) / Math.max(viewBox.width, viewBox.height);
|
|
});
|
|
expect(occupancy).toBeCloseTo(0.6, 4);
|
|
}
|
|
});
|
|
|
|
test("PNOR001 与两种 PNVO001 使用统一孔板风格和 small 画布", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const paletteFlowPath = page.locator(
|
|
'aside.palette [data-component-symbol="amesim_pnvo001_fixed"] [data-orifice-part="flow-path"]',
|
|
);
|
|
await expect(paletteFlowPath).toHaveCSS("stroke-width", "1.5px");
|
|
await expect(paletteFlowPath).toHaveCSS(
|
|
"vector-effect",
|
|
"non-scaling-stroke",
|
|
);
|
|
|
|
const cases = [
|
|
{
|
|
anchors: [
|
|
{ portName: "port_1", x: 12.8, y: 24 },
|
|
{ portName: "port_2", x: 51.2, y: 24 },
|
|
],
|
|
control: "none",
|
|
modelType: "amesim_pnor001",
|
|
target: { x: 220, y: 250 },
|
|
},
|
|
{
|
|
anchors: [
|
|
{ portName: "port_2", x: 12.8, y: 24 },
|
|
{ portName: "port_3", x: 51.2, y: 24 },
|
|
],
|
|
control: "fixed",
|
|
modelType: "amesim_pnvo001_fixed",
|
|
target: { x: 460, y: 250 },
|
|
},
|
|
{
|
|
anchors: [
|
|
{ portName: "res", x: 27.35, y: 43.2 },
|
|
{ portName: "port_2", x: 12.8, y: 24 },
|
|
{ portName: "port_3", x: 51.2, y: 24 },
|
|
],
|
|
control: "signal",
|
|
modelType: "amesim_pnvo001",
|
|
target: { x: 700, y: 250 },
|
|
},
|
|
] as const;
|
|
|
|
for (const item of cases) {
|
|
const node = await dragSymbolToCanvas(page, item.modelType, item.target);
|
|
const simNode = node.locator(".sim-node");
|
|
const symbol = node.locator(
|
|
`[data-component-symbol="${item.modelType}"]`,
|
|
);
|
|
const family = symbol.locator('[data-orifice-family="pnvo001"]');
|
|
const flowPath = family.locator('[data-orifice-part="flow-path"]');
|
|
const curves = family.locator('[data-orifice-part="curves"]');
|
|
const openingArrow = family.locator(
|
|
'[data-orifice-part="opening-arrow"]',
|
|
);
|
|
const signalControl = family.locator(
|
|
'[data-orifice-part="signal-control"]',
|
|
);
|
|
await expect(simNode).toHaveAttribute("data-node-layout-tier", "small");
|
|
const nodeBounds = await node.boundingBox();
|
|
expect(nodeBounds).not.toBeNull();
|
|
expect(Math.abs(nodeBounds!.width - 116)).toBeLessThan(1);
|
|
expect(Math.abs(nodeBounds!.height - 96)).toBeLessThan(1);
|
|
await expect(family).toHaveAttribute("data-orifice-control", item.control);
|
|
await expect(family).toHaveAttribute("transform", /scale\(0\.6\)/);
|
|
await expect(flowPath).toHaveAttribute("stroke-width", "1.5");
|
|
await expect(flowPath).toHaveAttribute("stroke", "#8b134f");
|
|
await expect(flowPath).toHaveCSS("stroke-width", "1.5px");
|
|
await expect(flowPath).toHaveCSS("vector-effect", "non-scaling-stroke");
|
|
await expect(curves).toHaveAttribute("stroke-width", "2.5");
|
|
await expect(curves).toHaveCSS("stroke-width", "2.5px");
|
|
await expect(curves).toHaveCSS("vector-effect", "non-scaling-stroke");
|
|
await expect(openingArrow).toHaveCount(item.control === "none" ? 0 : 1);
|
|
if (item.control !== "none") {
|
|
await expect(openingArrow).toHaveAttribute("stroke-width", "1.5");
|
|
await expect(openingArrow).toHaveCSS("stroke-width", "1.5px");
|
|
await expect(openingArrow).toHaveCSS(
|
|
"vector-effect",
|
|
"non-scaling-stroke",
|
|
);
|
|
}
|
|
await expect(signalControl).toHaveCount(item.control === "signal" ? 1 : 0);
|
|
if (item.control === "signal") {
|
|
await expect(signalControl).toHaveAttribute("stroke-width", "1.5");
|
|
await expect(signalControl).toHaveCSS("stroke-width", "1.5px");
|
|
await expect(signalControl).toHaveCSS(
|
|
"vector-effect",
|
|
"non-scaling-stroke",
|
|
);
|
|
}
|
|
const [familyBounds, symbolBounds, envelopeBounds] = await Promise.all([
|
|
family.boundingBox(),
|
|
symbol.boundingBox(),
|
|
node.locator('[data-symbol-envelope="true"]').boundingBox(),
|
|
]);
|
|
expect(familyBounds).not.toBeNull();
|
|
expect(symbolBounds).not.toBeNull();
|
|
expect(envelopeBounds).not.toBeNull();
|
|
expect(familyBounds!.width / symbolBounds!.width).toBeGreaterThan(0.58);
|
|
expect(familyBounds!.width / symbolBounds!.width).toBeLessThan(0.63);
|
|
expect(envelopeBounds!.x).toBeLessThanOrEqual(familyBounds!.x);
|
|
expect(envelopeBounds!.y).toBeLessThanOrEqual(familyBounds!.y);
|
|
expect(envelopeBounds!.x + envelopeBounds!.width).toBeGreaterThanOrEqual(
|
|
familyBounds!.x + familyBounds!.width,
|
|
);
|
|
expect(envelopeBounds!.y + envelopeBounds!.height).toBeGreaterThanOrEqual(
|
|
familyBounds!.y + familyBounds!.height,
|
|
);
|
|
expect(envelopeBounds!.width - familyBounds!.width).toBeGreaterThan(3);
|
|
expect(envelopeBounds!.width - familyBounds!.width).toBeLessThan(9);
|
|
expect(envelopeBounds!.height - familyBounds!.height).toBeGreaterThan(3);
|
|
expect(envelopeBounds!.height - familyBounds!.height).toBeLessThan(9);
|
|
if (item.control !== "none") {
|
|
const aspectRatio = familyBounds!.width / familyBounds!.height;
|
|
expect(aspectRatio).toBeGreaterThan(0.95);
|
|
expect(aspectRatio).toBeLessThan(1.05);
|
|
const envelopeAspectRatio =
|
|
envelopeBounds!.width / envelopeBounds!.height;
|
|
expect(envelopeAspectRatio).toBeGreaterThan(0.95);
|
|
expect(envelopeAspectRatio).toBeLessThan(1.05);
|
|
}
|
|
await expectSymbolAnchors(node, [...item.anchors]);
|
|
}
|
|
});
|
|
|
|
test("PNPL01 保持 compact 25% 叉形主体并延长接口引线", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const node = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 300,
|
|
y: 240,
|
|
});
|
|
const simNode = node.locator(".sim-node");
|
|
const symbol = node.locator('[data-component-symbol="amesim_pnpl01"]');
|
|
const svg = symbol.locator("svg");
|
|
const body = svg.locator('[data-pnpl01-part="body"]');
|
|
const extension = svg.locator('[data-pnpl01-part="extension"]');
|
|
const cross = svg.locator('[data-pnpl01-part="cross"]');
|
|
const handle = node.locator('.port-handle[data-port-name="port_1"]');
|
|
|
|
await expect(simNode).toHaveAttribute("data-node-layout-tier", "compact");
|
|
await expect(body).toHaveAttribute("transform", /scale\(0\.25\)/);
|
|
await expect(extension).toHaveAttribute("stroke-width", "1.5");
|
|
await expect(extension).toHaveAttribute("stroke", "#8b134f");
|
|
await expect(extension).toHaveCSS("stroke-width", "1.5px");
|
|
await expect(extension).toHaveAttribute("d", "M16 24H35.75");
|
|
await expect(cross).toHaveAttribute("stroke-width", "4");
|
|
await expect(cross).toHaveAttribute("stroke", "#8b134f");
|
|
await expect(cross).toHaveCSS("stroke-width", "4px");
|
|
await expect(extension).toHaveCSS("vector-effect", "non-scaling-stroke");
|
|
await expect(cross).toHaveCSS("vector-effect", "non-scaling-stroke");
|
|
await expectSymbolAnchors(node, [{ portName: "port_1", x: 16, y: 24 }]);
|
|
|
|
const [nodeBounds, contentGeometry] = await Promise.all([
|
|
node.boundingBox(),
|
|
body.evaluate((element: SVGGElement) => {
|
|
const bounds = element.getBBox();
|
|
const matrix = element.transform.baseVal.consolidate()?.matrix;
|
|
return {
|
|
height: bounds.height,
|
|
scale: matrix?.a ?? 1,
|
|
width: bounds.width,
|
|
};
|
|
}),
|
|
]);
|
|
expect(nodeBounds).not.toBeNull();
|
|
expect(Math.abs(nodeBounds!.width - 100)).toBeLessThan(1);
|
|
expect(Math.abs(nodeBounds!.height - 80)).toBeLessThan(1);
|
|
expect(contentGeometry.width).toBeCloseTo(34, 4);
|
|
expect(contentGeometry.height).toBeCloseTo(34, 4);
|
|
expect(contentGeometry.scale).toBeCloseTo(0.25, 4);
|
|
const [
|
|
crossBounds,
|
|
envelopeBounds,
|
|
symbolBounds,
|
|
extensionBounds,
|
|
handleBounds,
|
|
] = await Promise.all([
|
|
cross.boundingBox(),
|
|
node.locator('[data-symbol-envelope="true"]').boundingBox(),
|
|
symbol.boundingBox(),
|
|
extension.boundingBox(),
|
|
handle.boundingBox(),
|
|
]);
|
|
expect(crossBounds).not.toBeNull();
|
|
expect(envelopeBounds).not.toBeNull();
|
|
expect(symbolBounds).not.toBeNull();
|
|
expect(extensionBounds).not.toBeNull();
|
|
expect(handleBounds).not.toBeNull();
|
|
expect(extensionBounds!.x + extensionBounds!.width).toBeGreaterThan(
|
|
handleBounds!.x + handleBounds!.width + 12,
|
|
);
|
|
expect(Math.abs(crossBounds!.width - crossBounds!.height)).toBeLessThan(0.5);
|
|
const crossCenter = {
|
|
x: crossBounds!.x + crossBounds!.width / 2,
|
|
y: crossBounds!.y + crossBounds!.height / 2,
|
|
};
|
|
expect(crossCenter.x).toBeGreaterThan(envelopeBounds!.x);
|
|
expect(crossCenter.x).toBeLessThan(
|
|
envelopeBounds!.x + envelopeBounds!.width,
|
|
);
|
|
expect(crossCenter.y).toBeGreaterThan(envelopeBounds!.y);
|
|
expect(crossCenter.y).toBeLessThan(
|
|
envelopeBounds!.y + envelopeBounds!.height,
|
|
);
|
|
expect(envelopeBounds!.width / symbolBounds!.width).toBeLessThan(0.5);
|
|
expect(envelopeBounds!.height / symbolBounds!.height).toBeLessThan(0.5);
|
|
});
|
|
|
|
test("F000 保持 compact 25% 方框主体并延长接口引线", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
const node = await dragSymbolToCanvas(page, "amesim_f000", {
|
|
x: 300,
|
|
y: 240,
|
|
});
|
|
const simNode = node.locator(".sim-node");
|
|
const symbol = node.locator('[data-component-symbol="amesim_f000"]');
|
|
const body = symbol.locator('[data-f000-part="body"]');
|
|
const frame = symbol.locator('[data-f000-part="frame"]');
|
|
const extension = symbol.locator('[data-f000-part="extension"]');
|
|
const handle = node.locator('.port-handle[data-port-name="port_1"]');
|
|
|
|
await expect(simNode).toHaveAttribute("data-node-layout-tier", "compact");
|
|
await expect(body).toHaveAttribute("transform", /scale\(0\.25\)/);
|
|
await expect(frame).toHaveAttribute("stroke-width", "2.5");
|
|
await expect(frame).toHaveCSS("stroke-width", "2.5px");
|
|
await expect(extension).toHaveAttribute("stroke-width", "1.5");
|
|
await expect(extension).toHaveCSS("stroke-width", "1.5px");
|
|
await expect(extension).toHaveAttribute("d", "M37 24H48");
|
|
await expect(frame).toHaveCSS("vector-effect", "non-scaling-stroke");
|
|
await expect(extension).toHaveCSS("vector-effect", "non-scaling-stroke");
|
|
await expectSymbolAnchors(node, [{ portName: "port_1", x: 48, y: 24 }]);
|
|
|
|
const [nodeBounds, geometry] = await Promise.all([
|
|
node.boundingBox(),
|
|
body.evaluate((element: SVGGElement) => {
|
|
const bounds = element.getBBox();
|
|
const matrix = element.transform.baseVal.consolidate()?.matrix;
|
|
return {
|
|
height: bounds.height,
|
|
scale: matrix?.a ?? 1,
|
|
width: bounds.width,
|
|
};
|
|
}),
|
|
]);
|
|
expect(nodeBounds).not.toBeNull();
|
|
expect(Math.abs(nodeBounds!.width - 100)).toBeLessThan(1);
|
|
expect(Math.abs(nodeBounds!.height - 80)).toBeLessThan(1);
|
|
expect(geometry.width).toBeCloseTo(52, 4);
|
|
expect(geometry.height).toBeCloseTo(34, 4);
|
|
expect(geometry.scale).toBeCloseTo(0.25, 4);
|
|
const [extensionBounds, handleBounds] = await Promise.all([
|
|
extension.boundingBox(),
|
|
handle.boundingBox(),
|
|
]);
|
|
expect(extensionBounds).not.toBeNull();
|
|
expect(handleBounds).not.toBeNull();
|
|
expect(handleBounds!.x - extensionBounds!.x).toBeGreaterThan(6);
|
|
});
|
|
|
|
test("STEP0 与 UD00 使用 standard 画布并在工作区占比 60%", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const cases = [
|
|
{ modelType: "amesim_step0", source: "step0", x: 320 },
|
|
{ modelType: "amesim_ud00", source: "ud00", x: 560 },
|
|
] as const;
|
|
|
|
for (const item of cases) {
|
|
const node = await dragSymbolToCanvas(page, item.modelType, {
|
|
x: item.x,
|
|
y: 260,
|
|
});
|
|
const simNode = node.locator(".sim-node");
|
|
const symbol = node.locator(
|
|
`[data-component-symbol="${item.modelType}"]`,
|
|
);
|
|
const content = symbol.locator(`[data-signal-source="${item.source}"]`);
|
|
|
|
await expect(simNode).toHaveAttribute("data-node-layout-tier", "standard");
|
|
await expect(content).toHaveAttribute("transform", /scale\(0\.75294/);
|
|
await expectSymbolAnchors(node, [{ portName: "out", x: 51.2, y: 24 }]);
|
|
|
|
const [nodeBounds, symbolBounds, contentGeometry] = await Promise.all([
|
|
node.boundingBox(),
|
|
symbol.boundingBox(),
|
|
content.evaluate((element: SVGGElement) => {
|
|
const bounds = element.getBBox();
|
|
const matrix = element.transform.baseVal.consolidate()?.matrix;
|
|
return {
|
|
height: bounds.height,
|
|
scale: matrix?.a ?? 1,
|
|
width: bounds.width,
|
|
};
|
|
}),
|
|
]);
|
|
expect(nodeBounds).not.toBeNull();
|
|
expect(symbolBounds).not.toBeNull();
|
|
expect(Math.abs(nodeBounds!.width - 132)).toBeLessThan(1);
|
|
expect(Math.abs(nodeBounds!.height - 112)).toBeLessThan(1);
|
|
expect(contentGeometry.width).toBeCloseTo(51, 4);
|
|
expect(contentGeometry.height).toBeCloseTo(36, 4);
|
|
expect(
|
|
(contentGeometry.width * contentGeometry.scale) / 64,
|
|
).toBeCloseTo(0.6, 4);
|
|
const contentBounds = await content.boundingBox();
|
|
expect(contentBounds).not.toBeNull();
|
|
const visibleOccupancy = contentBounds!.width / symbolBounds!.width;
|
|
expect(visibleOccupancy).toBeGreaterThan(0.6);
|
|
// 独立描边不会随主体缩放,含描边的可见包络会略大于 60%。
|
|
expect(visibleOccupancy).toBeLessThan(0.63);
|
|
}
|
|
});
|
|
|
|
test("FORC 按参考图使用 standard 画布并在工作区占比 60%", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const node = await dragSymbolToCanvas(page, "amesim_forc", {
|
|
x: 420,
|
|
y: 280,
|
|
});
|
|
const simNode = node.locator(".sim-node");
|
|
const symbol = node.locator('[data-component-symbol="amesim_forc"]');
|
|
const content = symbol.locator('[data-forc-part="symbol"]');
|
|
const thickParts = symbol.locator(
|
|
'[data-forc-part="input-arrow"], [data-forc-part="force-circle"], [data-forc-part="mechanical-socket"]',
|
|
);
|
|
const thinParts = symbol.locator(
|
|
'[data-forc-part="left-extension"], [data-forc-part="right-extension"]',
|
|
);
|
|
const label = symbol.locator('[data-forc-part="label"]');
|
|
const circle = symbol.locator('[data-forc-part="force-circle"]');
|
|
const leftExtension = symbol.locator('[data-forc-part="left-extension"]');
|
|
const rightExtension = symbol.locator('[data-forc-part="right-extension"]');
|
|
const leftHandle = node.locator('.port-handle[data-port-name="res"]');
|
|
const rightHandle = node.locator('.port-handle[data-port-name="port_2"]');
|
|
|
|
await expect(simNode).toHaveAttribute("data-node-layout-tier", "standard");
|
|
await expect(content).toHaveAttribute("transform", /scale\(0\.64\)/);
|
|
await expect(thickParts).toHaveCount(3);
|
|
for (let index = 0; index < (await thickParts.count()); index += 1) {
|
|
await expect(thickParts.nth(index)).toHaveCSS("stroke-width", "2px");
|
|
await expect(thickParts.nth(index)).toHaveCSS(
|
|
"vector-effect",
|
|
"non-scaling-stroke",
|
|
);
|
|
}
|
|
await expect(thinParts).toHaveCount(2);
|
|
for (let index = 0; index < (await thinParts.count()); index += 1) {
|
|
await expect(thinParts.nth(index)).toHaveCSS("stroke-width", "1.5px");
|
|
await expect(thinParts.nth(index)).toHaveCSS(
|
|
"vector-effect",
|
|
"non-scaling-stroke",
|
|
);
|
|
}
|
|
await expect(label).toHaveText("F");
|
|
await expect(label).toHaveAttribute("font-size", "15");
|
|
await expect(label).toHaveCSS("font-weight", "600");
|
|
await expectSymbolAnchors(node, [
|
|
{ portName: "res", x: 12.8, y: 24 },
|
|
{ portName: "port_2", x: 51.2, y: 24 },
|
|
]);
|
|
|
|
const [
|
|
nodeBounds,
|
|
symbolBounds,
|
|
contentGeometry,
|
|
circleBounds,
|
|
labelBounds,
|
|
leftExtensionBounds,
|
|
rightExtensionBounds,
|
|
leftHandleBounds,
|
|
rightHandleBounds,
|
|
] = await Promise.all([
|
|
node.boundingBox(),
|
|
symbol.boundingBox(),
|
|
content.evaluate((element: SVGGElement) => {
|
|
const bounds = element.getBBox();
|
|
const matrix = element.transform.baseVal.consolidate()?.matrix;
|
|
return {
|
|
height: bounds.height,
|
|
scale: matrix?.a ?? 1,
|
|
width: bounds.width,
|
|
};
|
|
}),
|
|
circle.boundingBox(),
|
|
label.boundingBox(),
|
|
leftExtension.boundingBox(),
|
|
rightExtension.boundingBox(),
|
|
leftHandle.boundingBox(),
|
|
rightHandle.boundingBox(),
|
|
]);
|
|
expect(nodeBounds).not.toBeNull();
|
|
expect(symbolBounds).not.toBeNull();
|
|
expect(circleBounds).not.toBeNull();
|
|
expect(labelBounds).not.toBeNull();
|
|
expect(leftExtensionBounds).not.toBeNull();
|
|
expect(rightExtensionBounds).not.toBeNull();
|
|
expect(leftHandleBounds).not.toBeNull();
|
|
expect(rightHandleBounds).not.toBeNull();
|
|
expect(Math.abs(nodeBounds!.width - 132)).toBeLessThan(1);
|
|
expect(Math.abs(nodeBounds!.height - 112)).toBeLessThan(1);
|
|
expect(contentGeometry.width).toBeCloseTo(60, 4);
|
|
expect(contentGeometry.scale).toBeCloseTo(0.64, 4);
|
|
expect(
|
|
(contentGeometry.width * contentGeometry.scale) / 64,
|
|
).toBeCloseTo(0.6, 4);
|
|
expect(labelBounds!.height / circleBounds!.height).toBeGreaterThan(0.58);
|
|
expect(labelBounds!.height / circleBounds!.height).toBeLessThan(0.68);
|
|
expect(leftExtensionBounds!.x + leftExtensionBounds!.width).toBeGreaterThan(
|
|
leftHandleBounds!.x + leftHandleBounds!.width + 8,
|
|
);
|
|
expect(rightHandleBounds!.x - rightExtensionBounds!.x).toBeGreaterThan(4);
|
|
const contentBounds = await content.boundingBox();
|
|
expect(contentBounds).not.toBeNull();
|
|
expect(contentBounds!.width / symbolBounds!.width).toBeGreaterThan(0.59);
|
|
expect(contentBounds!.width / symbolBounds!.width).toBeLessThan(0.62);
|
|
});
|
|
|
|
test("LSTP00A 使用 standard 画布、65% 工作区占比、3px 线宽和外露引线", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const paletteSymbol = page.locator(
|
|
'aside.palette [data-component-symbol="amesim_lstp00a"]',
|
|
);
|
|
await expect(paletteSymbol).toHaveAttribute("data-symbol-size-tier", "standard");
|
|
await expect(paletteSymbol).toHaveAttribute("data-palette-occupancy", "0.9");
|
|
|
|
const node = await dragSymbolToCanvas(page, "amesim_lstp00a", {
|
|
x: 420,
|
|
y: 280,
|
|
});
|
|
const simNode = node.locator(".sim-node");
|
|
const symbol = node.locator('[data-component-symbol="amesim_lstp00a"]');
|
|
const content = symbol.locator('[data-lstp00a-part="symbol"]');
|
|
const lineParts = symbol.locator(
|
|
'[data-lstp00a-part="left-extension"], [data-lstp00a-part="right-extension"], [data-lstp00a-part="left-socket"], [data-lstp00a-part="right-socket"], [data-lstp00a-part="left-rail"], [data-lstp00a-part="middle-rail"], [data-lstp00a-part="right-rail"], [data-lstp00a-part="spring"], [data-lstp00a-part="lower-link"]',
|
|
);
|
|
const leftExtension = symbol.locator(
|
|
'[data-lstp00a-part="left-extension"]',
|
|
);
|
|
const rightExtension = symbol.locator(
|
|
'[data-lstp00a-part="right-extension"]',
|
|
);
|
|
const leftHandle = node.locator('.port-handle[data-port-name="port_1"]');
|
|
const rightHandle = node.locator('.port-handle[data-port-name="port_2"]');
|
|
|
|
await expect(simNode).toHaveAttribute("data-node-layout-tier", "standard");
|
|
await expect(content).toHaveAttribute("transform", /scale\(0\.693333/);
|
|
await expect(content).toHaveAttribute("stroke", "#00af00");
|
|
await expect(content).toHaveAttribute("stroke-width", "3");
|
|
await expect(lineParts).toHaveCount(9);
|
|
for (let index = 0; index < (await lineParts.count()); index += 1) {
|
|
await expect(lineParts.nth(index)).toHaveCSS("stroke-width", "3px");
|
|
await expect(lineParts.nth(index)).toHaveCSS(
|
|
"vector-effect",
|
|
"non-scaling-stroke",
|
|
);
|
|
}
|
|
await expectSymbolAnchors(node, [
|
|
{ portName: "port_1", x: 11.2, y: 24 },
|
|
{ portName: "port_2", x: 52.8, y: 24 },
|
|
]);
|
|
|
|
const [
|
|
nodeBounds,
|
|
symbolBounds,
|
|
contentGeometry,
|
|
leftExtensionBounds,
|
|
rightExtensionBounds,
|
|
leftHandleBounds,
|
|
rightHandleBounds,
|
|
] = await Promise.all([
|
|
node.boundingBox(),
|
|
symbol.boundingBox(),
|
|
content.evaluate((element: SVGGElement) => {
|
|
const bounds = element.getBBox();
|
|
const matrix = element.transform.baseVal.consolidate()?.matrix;
|
|
return {
|
|
height: bounds.height,
|
|
scale: matrix?.a ?? 1,
|
|
width: bounds.width,
|
|
};
|
|
}),
|
|
leftExtension.boundingBox(),
|
|
rightExtension.boundingBox(),
|
|
leftHandle.boundingBox(),
|
|
rightHandle.boundingBox(),
|
|
]);
|
|
expect(nodeBounds).not.toBeNull();
|
|
expect(symbolBounds).not.toBeNull();
|
|
expect(leftExtensionBounds).not.toBeNull();
|
|
expect(rightExtensionBounds).not.toBeNull();
|
|
expect(leftHandleBounds).not.toBeNull();
|
|
expect(rightHandleBounds).not.toBeNull();
|
|
expect(Math.abs(nodeBounds!.width - 132)).toBeLessThan(1);
|
|
expect(Math.abs(nodeBounds!.height - 112)).toBeLessThan(1);
|
|
expect(contentGeometry.width).toBeCloseTo(60, 4);
|
|
expect(contentGeometry.height).toBeCloseTo(53, 4);
|
|
expect(contentGeometry.scale).toBeCloseTo(0.693333, 4);
|
|
expect(
|
|
(contentGeometry.width * contentGeometry.scale) / 64,
|
|
).toBeCloseTo(0.65, 4);
|
|
expect(symbolBounds!.width / symbolBounds!.height).toBeCloseTo(1, 2);
|
|
expect(
|
|
leftExtensionBounds!.x +
|
|
leftExtensionBounds!.width -
|
|
(leftHandleBounds!.x + leftHandleBounds!.width),
|
|
).toBeGreaterThan(1.5);
|
|
expect(rightHandleBounds!.x - rightExtensionBounds!.x).toBeGreaterThan(1.5);
|
|
});
|
|
|
|
test("PNCH023 与 PNCH012 使用相同的气室主体尺寸", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
const fixedNode = await dragSymbolToCanvas(page, "amesim_pnch023", {
|
|
x: 300,
|
|
y: 260,
|
|
});
|
|
const variableNode = await dragSymbolToCanvas(page, "amesim_pnch012", {
|
|
x: 540,
|
|
y: 260,
|
|
});
|
|
const fixedSymbol = fixedNode.locator(
|
|
'[data-component-symbol="amesim_pnch023"]',
|
|
);
|
|
const variableSymbol = variableNode.locator(
|
|
'[data-component-symbol="amesim_pnch012"]',
|
|
);
|
|
const fixedContent = fixedSymbol.locator('[data-pnch023-part="symbol"]');
|
|
const variableContent = variableSymbol.locator(
|
|
'[data-pnch012-part="symbol"]',
|
|
);
|
|
const fixedLabel = fixedSymbol.locator('[data-pnch023-part="label"]');
|
|
|
|
await expect(fixedNode.locator(".sim-node")).toHaveAttribute(
|
|
"data-node-layout-tier",
|
|
"small",
|
|
);
|
|
await expect(fixedContent).toHaveAttribute("transform", /scale\(0\.45\)/);
|
|
await expect(
|
|
fixedSymbol.locator('[data-pnch023-part="chamber"]'),
|
|
).toHaveAttribute("r", "21");
|
|
await expect(fixedSymbol.locator('[data-pnch023-part="ports"]')).toHaveCSS(
|
|
"stroke-width",
|
|
"2px",
|
|
);
|
|
await expect(fixedLabel).toHaveText("Cp");
|
|
await expect(fixedLabel).toHaveAttribute("fill", "#8b134f");
|
|
await expect(fixedLabel).toHaveCSS("font-weight", "600");
|
|
await expect(fixedLabel).toHaveCSS("stroke", "none");
|
|
expect(await fixedLabel.getAttribute("stroke-width")).toBeNull();
|
|
await expect(fixedContent.locator('[data-pnch023-part="label"]')).toHaveCount(
|
|
0,
|
|
);
|
|
const renderedLabelSize = await fixedLabel.evaluate(
|
|
(element: SVGTextElement) => {
|
|
const svg = element.ownerSVGElement;
|
|
if (!svg) {
|
|
return 0;
|
|
}
|
|
const fontSize = Number.parseFloat(getComputedStyle(element).fontSize);
|
|
const viewBoxScale =
|
|
svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
|
|
return fontSize * viewBoxScale;
|
|
},
|
|
);
|
|
expect(renderedLabelSize).toBeCloseTo(15, 1);
|
|
await expectSymbolAnchors(fixedNode, [
|
|
{ portName: "port_1", x: 17.6, y: 24 },
|
|
{ portName: "port_2", x: 46.4, y: 24 },
|
|
]);
|
|
|
|
const [fixedNodeBounds, fixedContentBounds, variableContentBounds] =
|
|
await Promise.all([
|
|
fixedNode.boundingBox(),
|
|
fixedContent.boundingBox(),
|
|
variableContent.boundingBox(),
|
|
]);
|
|
expect(fixedNodeBounds).not.toBeNull();
|
|
expect(fixedContentBounds).not.toBeNull();
|
|
expect(variableContentBounds).not.toBeNull();
|
|
expect(Math.abs(fixedNodeBounds!.width - 116)).toBeLessThan(1);
|
|
expect(Math.abs(fixedNodeBounds!.height - 96)).toBeLessThan(1);
|
|
expect(
|
|
Math.abs(fixedContentBounds!.width - variableContentBounds!.width),
|
|
).toBeLessThan(3);
|
|
});
|
|
|
|
test("PNCH012 按参考图使用 45% 结构与独立 15px 文字", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const node = await dragSymbolToCanvas(page, "amesim_pnch012", {
|
|
x: 360,
|
|
y: 260,
|
|
});
|
|
const simNode = node.locator(".sim-node");
|
|
const symbol = node.locator('[data-component-symbol="amesim_pnch012"]');
|
|
const svg = symbol.locator("svg");
|
|
const content = svg.locator('[data-pnch012-part="symbol"]');
|
|
const structuralParts = svg.locator(
|
|
'[data-pnch012-part="ports"], [data-pnch012-part="chamber"], [data-pnch012-part="volume-arrow"]',
|
|
);
|
|
const chamber = svg.locator('[data-pnch012-part="chamber"]');
|
|
const arrows = svg.locator('[data-pnch012-part="volume-arrow"]');
|
|
const label = svg.locator('[data-pnch012-part="label"]');
|
|
|
|
await expect(simNode).toHaveAttribute("data-node-layout-tier", "small");
|
|
await expect(content).toHaveAttribute("transform", /scale\(0\.45\)/);
|
|
await expect(chamber).toHaveAttribute("r", "21");
|
|
await expect(arrows).toHaveCount(4);
|
|
await expect(structuralParts).toHaveCount(6);
|
|
for (let index = 0; index < (await structuralParts.count()); index += 1) {
|
|
const part = structuralParts.nth(index);
|
|
await expect(part).toHaveCSS("stroke-width", "1.5px");
|
|
await expect(part).toHaveCSS("vector-effect", "non-scaling-stroke");
|
|
}
|
|
await expect(label).toHaveText("Cp");
|
|
await expect(label).toHaveAttribute("font-weight", "600");
|
|
await expect(label).toHaveCSS("font-weight", "600");
|
|
await expect(label).toHaveCSS("stroke", "none");
|
|
expect(await label.getAttribute("stroke-width")).toBeNull();
|
|
await expect(label).toHaveCSS("vector-effect", "non-scaling-stroke");
|
|
await expect(content.locator('[data-pnch012-part="label"]')).toHaveCount(0);
|
|
await expect(svg.locator(':scope > [data-pnch012-part="label"]')).toHaveCount(
|
|
1,
|
|
);
|
|
const renderedLabelSize = await label.evaluate((element: SVGTextElement) => {
|
|
const svg = element.ownerSVGElement;
|
|
if (!svg) {
|
|
return 0;
|
|
}
|
|
const fontSize = Number.parseFloat(getComputedStyle(element).fontSize);
|
|
const viewBoxScale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
|
|
return fontSize * viewBoxScale;
|
|
});
|
|
expect(renderedLabelSize).toBeCloseTo(15, 1);
|
|
await expectSymbolAnchors(node, [
|
|
{ portName: "port_1", x: 17.6, y: 24 },
|
|
{ portName: "port_3", x: 32, y: 9.6 },
|
|
{ portName: "port_2", x: 46.4, y: 24 },
|
|
{ portName: "port_4", x: 32, y: 38.4 },
|
|
]);
|
|
|
|
const nodeBounds = await node.boundingBox();
|
|
expect(nodeBounds).not.toBeNull();
|
|
expect(Math.abs(nodeBounds!.width - 116)).toBeLessThan(1);
|
|
expect(Math.abs(nodeBounds!.height - 96)).toBeLessThan(1);
|
|
const contentGeometry = await content.evaluate((element: SVGGElement) => {
|
|
const bounds = element.getBBox();
|
|
const matrix = element.transform.baseVal.consolidate()?.matrix;
|
|
return {
|
|
height: bounds.height,
|
|
scale: matrix?.a ?? 1,
|
|
width: bounds.width,
|
|
};
|
|
});
|
|
expect(contentGeometry.width).toBeCloseTo(64, 4);
|
|
expect(contentGeometry.height).toBeCloseTo(64, 4);
|
|
expect(contentGeometry.scale).toBeCloseTo(0.45, 4);
|
|
expect((contentGeometry.width * contentGeometry.scale) / 64).toBeCloseTo(
|
|
0.45,
|
|
4,
|
|
);
|
|
});
|
|
|
|
test("不同尺寸档位使用紧凑节点框且旋转时保持中心", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
|
const paneBounds = await page.locator(".flow-canvas .react-flow__pane").boundingBox();
|
|
expect(paneBounds).not.toBeNull();
|
|
|
|
const cases = [
|
|
{
|
|
modelType: "amesim_pnor001",
|
|
size: { width: 116, height: 96 },
|
|
target: { x: 130, y: 150 },
|
|
tier: "small",
|
|
},
|
|
{
|
|
modelType: "amesim_pnl00r",
|
|
size: { width: 132, height: 112 },
|
|
target: { x: 360, y: 150 },
|
|
tier: "standard",
|
|
},
|
|
{
|
|
modelType: "amesim_pnvo001",
|
|
size: { width: 116, height: 96 },
|
|
target: { x: 600, y: 150 },
|
|
tier: "small",
|
|
},
|
|
{
|
|
modelType: "amesim_mecmas21",
|
|
size: { width: 132, height: 112 },
|
|
target: { x: 130, y: 390 },
|
|
tier: "standard",
|
|
},
|
|
{
|
|
modelType: "amesim_lmechn1",
|
|
size: { width: 148, height: 96 },
|
|
target: { x: 360, y: 390 },
|
|
tier: "custom",
|
|
},
|
|
{
|
|
modelType: "amesim_helium_medium",
|
|
size: { width: 112, height: 112 },
|
|
target: { x: 600, y: 390 },
|
|
tier: "standard",
|
|
},
|
|
] as const;
|
|
const nodes = new Map<string, Locator>();
|
|
|
|
for (const item of cases) {
|
|
const node = await dragSymbolToCanvas(page, item.modelType, item.target);
|
|
nodes.set(item.modelType, node);
|
|
const simNode = node.locator(".sim-node");
|
|
await expect(simNode).toHaveAttribute("data-node-layout-tier", item.tier);
|
|
const [nodeBounds, simNodeBounds] = await Promise.all([
|
|
node.boundingBox(),
|
|
simNode.boundingBox(),
|
|
]);
|
|
expect(nodeBounds).not.toBeNull();
|
|
expect(simNodeBounds).not.toBeNull();
|
|
expect(Math.abs(nodeBounds!.width - item.size.width)).toBeLessThan(1);
|
|
expect(Math.abs(nodeBounds!.height - item.size.height)).toBeLessThan(1);
|
|
expect(Math.abs(simNodeBounds!.width - item.size.width)).toBeLessThan(1);
|
|
expect(Math.abs(simNodeBounds!.height - item.size.height)).toBeLessThan(1);
|
|
expect(
|
|
Math.abs(
|
|
nodeBounds!.x + nodeBounds!.width / 2 -
|
|
(paneBounds!.x + item.target.x),
|
|
),
|
|
).toBeLessThan(2);
|
|
expect(
|
|
Math.abs(
|
|
nodeBounds!.y + nodeBounds!.height / 2 -
|
|
(paneBounds!.y + item.target.y),
|
|
),
|
|
).toBeLessThan(2);
|
|
}
|
|
|
|
const pipeNode = nodes.get("amesim_pnl00r") as Locator;
|
|
await pipeNode.locator('[data-symbol-envelope="true"]').click();
|
|
const beforeRotation = await pipeNode.boundingBox();
|
|
expect(beforeRotation).not.toBeNull();
|
|
await page
|
|
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
|
|
.click();
|
|
const afterRotation = await pipeNode.boundingBox();
|
|
expect(afterRotation).not.toBeNull();
|
|
expect(Math.abs(afterRotation!.width - beforeRotation!.height)).toBeLessThan(1);
|
|
expect(Math.abs(afterRotation!.height - beforeRotation!.width)).toBeLessThan(1);
|
|
expect(
|
|
Math.abs(
|
|
afterRotation!.x + afterRotation!.width / 2 -
|
|
(beforeRotation!.x + beforeRotation!.width / 2),
|
|
),
|
|
).toBeLessThan(1.5);
|
|
expect(
|
|
Math.abs(
|
|
afterRotation!.y + afterRotation!.height / 2 -
|
|
(beforeRotation!.y + beforeRotation!.height / 2),
|
|
),
|
|
).toBeLessThan(1.5);
|
|
|
|
const denseHandles = await (
|
|
nodes.get("amesim_lmechn1") as Locator
|
|
)
|
|
.locator('.port-handle.react-flow__handle-right[data-port-name^="port_"]')
|
|
.evaluateAll((elements) =>
|
|
elements
|
|
.map((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
return { top: bounds.top, bottom: bounds.bottom };
|
|
})
|
|
.sort((first, second) => first.top - second.top),
|
|
);
|
|
for (let index = 1; index < denseHandles.length; index += 1) {
|
|
expect(denseHandles[index].top).toBeGreaterThanOrEqual(
|
|
denseHandles[index - 1].bottom - 0.5,
|
|
);
|
|
}
|
|
|
|
await page.getByRole("button", { name: "开启网格吸附", exact: true }).click();
|
|
const snappedTarget = { x: 600, y: 610 };
|
|
const snappedNode = await dragSymbolToCanvas(
|
|
page,
|
|
"amesim_f000",
|
|
snappedTarget,
|
|
);
|
|
const snappedBounds = await snappedNode.boundingBox();
|
|
expect(snappedBounds).not.toBeNull();
|
|
expect(
|
|
Math.abs(
|
|
snappedBounds!.x + snappedBounds!.width / 2 -
|
|
(paneBounds!.x + snappedTarget.x),
|
|
),
|
|
).toBeLessThanOrEqual(10);
|
|
expect(
|
|
Math.abs(
|
|
snappedBounds!.y + snappedBounds!.height / 2 -
|
|
(paneBounds!.y + snappedTarget.y),
|
|
),
|
|
).toBeLessThanOrEqual(10);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const raw = window.localStorage.getItem(
|
|
"system-simulation-flow:autosave",
|
|
);
|
|
const project = raw ? JSON.parse(raw) : null;
|
|
const position = project?.nodes?.at(-1)?.position;
|
|
return position
|
|
? { xRemainder: position.x % 18, yRemainder: position.y % 18 }
|
|
: null;
|
|
}),
|
|
)
|
|
.toEqual({ xRemainder: 0, yRemainder: 0 });
|
|
});
|
|
|
|
test("LMECHN1 按右侧端口数展开工作区画布且组件库图标保持固定", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const paletteSymbol = page.locator(
|
|
'aside.palette [data-component-symbol="amesim_lmechn1"]',
|
|
);
|
|
await expect(paletteSymbol).toHaveAttribute("data-palette-occupancy", "0.9");
|
|
await expect(
|
|
paletteSymbol.locator('[data-lmechn1-part="right-port-socket"]'),
|
|
).toHaveCount(5);
|
|
await expect(
|
|
paletteSymbol.locator('[data-lmechn1-part="reference-port-socket"]'),
|
|
).toHaveCount(1);
|
|
const paletteBounds = await paletteSymbol.boundingBox();
|
|
expect(paletteBounds).not.toBeNull();
|
|
|
|
const node = await dragSymbolToCanvas(page, "amesim_lmechn1", {
|
|
x: 520,
|
|
y: 330,
|
|
});
|
|
const initialBounds = await node.boundingBox();
|
|
expect(initialBounds).not.toBeNull();
|
|
expect(Math.abs(initialBounds!.width - 148)).toBeLessThan(1);
|
|
expect(Math.abs(initialBounds!.height - 96)).toBeLessThan(1);
|
|
const body = node.locator('[data-lmechn1-part="body"]');
|
|
await expect(body).toHaveAttribute("stroke-width", "2.5");
|
|
await expect(
|
|
node.locator('[data-lmechn1-part="right-port-socket"]'),
|
|
).toHaveCount(2);
|
|
await expect(
|
|
node.locator('[data-lmechn1-part="reference-port-socket"]'),
|
|
).toHaveCount(1);
|
|
const detailStrokeWidths = await node
|
|
.locator(
|
|
'[data-lmechn1-part="right-port-line"], [data-lmechn1-part="right-port-socket"], [data-lmechn1-part="reference-port-line"], [data-lmechn1-part="reference-port-socket"]',
|
|
)
|
|
.evaluateAll((elements) =>
|
|
elements.map((element) => element.getAttribute("stroke-width")),
|
|
);
|
|
expect(new Set(detailStrokeWidths)).toEqual(new Set(["1.5"]));
|
|
const connectorGeometry = await node
|
|
.locator("g[data-lmechn1-port]")
|
|
.evaluateAll((groups) =>
|
|
groups.map((group) => {
|
|
const socket = group.querySelector<SVGGraphicsElement>(
|
|
'[data-lmechn1-part="right-port-socket"]',
|
|
);
|
|
const line = group.querySelector<SVGGraphicsElement>(
|
|
'[data-lmechn1-part="right-port-line"]',
|
|
);
|
|
if (!socket || !line) {
|
|
throw new Error("LMECHN1 connector geometry is incomplete");
|
|
}
|
|
const socketBox = socket.getBBox();
|
|
const lineBox = line.getBBox();
|
|
return {
|
|
axisOffset: Math.abs(
|
|
socketBox.y + socketBox.height / 2 -
|
|
(lineBox.y + lineBox.height / 2),
|
|
),
|
|
lineWidth: lineBox.width,
|
|
socketHeight: socketBox.height,
|
|
socketWidth: socketBox.width,
|
|
};
|
|
}),
|
|
);
|
|
expect(connectorGeometry).toHaveLength(2);
|
|
for (const connector of connectorGeometry) {
|
|
expect(Math.abs(connector.socketWidth - 6)).toBeLessThan(0.01);
|
|
expect(Math.abs(connector.socketHeight - 5)).toBeLessThan(0.01);
|
|
expect(Math.abs(connector.lineWidth - 10)).toBeLessThan(0.01);
|
|
expect(connector.axisOffset).toBeLessThan(0.01);
|
|
}
|
|
await expect(node.locator(".port-handle")).toHaveCount(3);
|
|
await expect(
|
|
node.locator('.port-handle.react-flow__handle-left[data-port-name="port_3"]'),
|
|
).toHaveCount(1);
|
|
await expect(
|
|
node.locator('.port-handle.react-flow__handle-right[data-port-name^="port_"]'),
|
|
).toHaveCount(2);
|
|
await expectPortIndexLabels(node, { port_1: "1", port_2: "2", port_3: "3" });
|
|
|
|
const portCount = page.getByRole("textbox", { name: "右侧端口数" });
|
|
await portCount.fill("1");
|
|
await portCount.press("Enter");
|
|
await expect(node.locator(".port-handle")).toHaveCount(2);
|
|
await expect(
|
|
node.locator('.port-handle.react-flow__handle-left[data-port-name="port_2"]'),
|
|
).toHaveCount(1);
|
|
await expect(
|
|
node.locator('.port-handle.react-flow__handle-right[data-port-name="port_1"]'),
|
|
).toHaveCount(1);
|
|
await expect(
|
|
node.locator('[data-lmechn1-part="right-port-socket"]'),
|
|
).toHaveCount(1);
|
|
await expectPortIndexLabels(node, { port_1: "1", port_2: "2" });
|
|
const onePortBounds = await node.boundingBox();
|
|
expect(onePortBounds).not.toBeNull();
|
|
expect(Math.abs(onePortBounds!.height - 96)).toBeLessThan(1);
|
|
expect(
|
|
Math.abs(
|
|
onePortBounds!.x + onePortBounds!.width / 2 -
|
|
(initialBounds!.x + initialBounds!.width / 2),
|
|
),
|
|
).toBeLessThan(1.5);
|
|
expect(
|
|
Math.abs(
|
|
onePortBounds!.y + onePortBounds!.height / 2 -
|
|
(initialBounds!.y + initialBounds!.height / 2),
|
|
),
|
|
).toBeLessThan(1.5);
|
|
|
|
await portCount.fill("20");
|
|
await portCount.press("Enter");
|
|
await expect(node.locator(".port-handle")).toHaveCount(21);
|
|
await expect(
|
|
node.locator('.port-handle.react-flow__handle-left[data-port-name="port_21"]'),
|
|
).toHaveCount(1);
|
|
await expect(
|
|
node.locator('.port-handle.react-flow__handle-right[data-port-name^="port_"]'),
|
|
).toHaveCount(20);
|
|
await expect(
|
|
node.locator('[data-lmechn1-part="right-port-socket"]'),
|
|
).toHaveCount(20);
|
|
const twentyPortBounds = await node.boundingBox();
|
|
expect(twentyPortBounds).not.toBeNull();
|
|
expect(Math.abs(twentyPortBounds!.height - 534)).toBeLessThan(1);
|
|
|
|
await expect(
|
|
page.getByRole("combobox", { name: "节点求和模式" }),
|
|
).toHaveCount(0);
|
|
await expect(page.getByText("节点求和模式", { exact: true })).toHaveCount(0);
|
|
|
|
const rightPortLabels = node.locator(
|
|
'.port-index-label:not([data-port-name="port_21"])',
|
|
);
|
|
await expect(rightPortLabels).toHaveCount(20);
|
|
const verticalLabelLayout = await rightPortLabels.evaluateAll((elements) => {
|
|
const boxes = elements
|
|
.map((element) => element.getBoundingClientRect())
|
|
.sort((first, second) => first.top - second.top);
|
|
const centers = boxes.map((box) => box.left + box.width / 2);
|
|
return {
|
|
columnSpread: Math.max(...centers) - Math.min(...centers),
|
|
overlaps: boxes.slice(1).some((box, index) => box.top < boxes[index].bottom),
|
|
};
|
|
});
|
|
expect(verticalLabelLayout.columnSpread).toBeLessThan(1);
|
|
expect(verticalLabelLayout.overlaps).toBe(false);
|
|
|
|
await page
|
|
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
|
|
.click();
|
|
const horizontalLabelLayout = await rightPortLabels.evaluateAll((elements) => {
|
|
const boxes = elements
|
|
.map((element) => element.getBoundingClientRect())
|
|
.sort((first, second) => first.left - second.left);
|
|
const centers = boxes.map((box) => box.top + box.height / 2);
|
|
return {
|
|
rowSpread: Math.max(...centers) - Math.min(...centers),
|
|
};
|
|
});
|
|
expect(horizontalLabelLayout.rowSpread).toBeLessThan(1);
|
|
|
|
await portCount.fill("21");
|
|
await portCount.press("Enter");
|
|
await expect(
|
|
page.getByText("右侧端口数量不能超过20", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(node.locator(".port-handle")).toHaveCount(21);
|
|
|
|
const paletteBoundsAfter = await paletteSymbol.boundingBox();
|
|
expect(paletteBoundsAfter).not.toBeNull();
|
|
expect(Math.abs(paletteBoundsAfter!.width - paletteBounds!.width)).toBeLessThan(
|
|
0.5,
|
|
);
|
|
expect(
|
|
Math.abs(paletteBoundsAfter!.height - paletteBounds!.height),
|
|
).toBeLessThan(0.5);
|
|
});
|
|
|
|
test("旧版 LMECHN1 工程会把固定参考端口迁移到动态最大编号", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const node = await dragSymbolToCanvas(page, "amesim_lmechn1", {
|
|
x: 520,
|
|
y: 330,
|
|
});
|
|
const portCount = page.getByRole("textbox", { name: "右侧端口数" });
|
|
await portCount.fill("2");
|
|
await portCount.press("Enter");
|
|
const terminator = await dragSymbolToCanvas(page, "amesim_f000", {
|
|
x: 280,
|
|
y: 330,
|
|
});
|
|
await node.locator('[data-port-name="port_3"]').click();
|
|
await terminator.locator('[data-port-name="port_1"]').click();
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
|
|
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
|
const downgraded = await page.evaluate(() => {
|
|
const key = Object.keys(localStorage).find((candidate) =>
|
|
candidate.startsWith("system-simulation-flow:project:"),
|
|
);
|
|
const raw = key ? localStorage.getItem(key) : null;
|
|
if (!key || !raw) {
|
|
return false;
|
|
}
|
|
const project = JSON.parse(raw) as {
|
|
nodes: Array<{
|
|
id: string;
|
|
data: {
|
|
componentType: string;
|
|
modelVersion?: string;
|
|
ports: unknown[];
|
|
parameters: Record<string, unknown>;
|
|
};
|
|
}>;
|
|
edges: Array<{
|
|
source: string;
|
|
target: string;
|
|
sourceHandle: string;
|
|
targetHandle: string;
|
|
}>;
|
|
};
|
|
const legacyNode = project.nodes.find(
|
|
(candidate) => candidate.data.componentType === "amesim_lmechn1",
|
|
);
|
|
if (!legacyNode) {
|
|
return false;
|
|
}
|
|
legacyNode.data.modelVersion = "0.1.0";
|
|
legacyNode.data.ports = legacyNode.data.ports.slice(0, 9);
|
|
legacyNode.data.parameters.sum = 2;
|
|
project.edges.forEach((edge) => {
|
|
if (edge.source === legacyNode.id) {
|
|
edge.sourceHandle = "port_9";
|
|
}
|
|
if (edge.target === legacyNode.id) {
|
|
edge.targetHandle = "port_9";
|
|
}
|
|
});
|
|
localStorage.setItem(key, JSON.stringify(project));
|
|
return true;
|
|
});
|
|
expect(downgraded).toBe(true);
|
|
|
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
|
const migratedNode = page.locator(
|
|
'.flow-canvas .react-flow__node[data-id^="amesim_lmechn1_"]',
|
|
);
|
|
await expect(migratedNode.locator(".port-handle")).toHaveCount(3);
|
|
await expect(
|
|
migratedNode.locator(
|
|
'.port-handle.react-flow__handle-left[data-port-name="port_3"]',
|
|
),
|
|
).toHaveCount(1);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
|
await page.getByRole("button", { name: "展开仿真控制台" }).click();
|
|
await expect(
|
|
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
|
).toContainText("已升级 1 个 LMECHN1 元件的动态端口合同");
|
|
|
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const key = Object.keys(localStorage).find((candidate) =>
|
|
candidate.startsWith("system-simulation-flow:project:"),
|
|
);
|
|
const raw = key ? localStorage.getItem(key) : null;
|
|
const project = raw ? JSON.parse(raw) : null;
|
|
const migrated = project?.nodes?.find(
|
|
(candidate: { data?: { componentType?: string } }) =>
|
|
candidate.data?.componentType === "amesim_lmechn1",
|
|
);
|
|
const edge = project?.edges?.[0];
|
|
const endpoint = edge?.source === migrated?.id
|
|
? edge?.sourceHandle
|
|
: edge?.target === migrated?.id
|
|
? edge?.targetHandle
|
|
: null;
|
|
return {
|
|
modelVersion: migrated?.data?.modelVersion,
|
|
portCount: migrated?.data?.ports?.length,
|
|
sum: migrated?.data?.parameters?.sum,
|
|
endpoint,
|
|
};
|
|
}),
|
|
)
|
|
.toEqual({
|
|
modelVersion: "0.2.0",
|
|
portCount: 21,
|
|
sum: 1,
|
|
endpoint: "port_3",
|
|
});
|
|
});
|
|
|
|
test("LMECHN1 动态参考端口使用实际锚点参与接触吸附", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
|
|
|
const node = await dragSymbolToCanvas(page, "amesim_lmechn1", {
|
|
x: 600,
|
|
y: 330,
|
|
});
|
|
const portCount = page.getByRole("textbox", { name: "右侧端口数" });
|
|
await portCount.fill("1");
|
|
await portCount.press("Enter");
|
|
const boundary = await dragSymbolToCanvas(page, "amesim_f000", {
|
|
x: 260,
|
|
y: 330,
|
|
});
|
|
const referencePort = node.locator(
|
|
'.port-handle.react-flow__handle-left[data-port-name="port_2"]',
|
|
);
|
|
const boundaryPort = boundary.locator(
|
|
'.port-handle[data-port-name="port_1"]',
|
|
);
|
|
|
|
await dragNodePortNearTarget(
|
|
page,
|
|
boundary,
|
|
boundaryPort,
|
|
referencePort,
|
|
8,
|
|
);
|
|
await expect(referencePort).toHaveAttribute(
|
|
"data-connection-state",
|
|
"compatible",
|
|
);
|
|
await expect(boundaryPort).toHaveAttribute("data-connection-state", "origin");
|
|
await page.mouse.up();
|
|
|
|
await expect
|
|
.poll(() => distanceBetween(boundaryPort, referencePort))
|
|
.toBeLessThan(2);
|
|
const contactEdge = page.locator(".flow-canvas .react-flow__edge");
|
|
await expect(contactEdge).toHaveCount(1);
|
|
await expect(contactEdge).toHaveClass(/editor-edge-contact/);
|
|
await expect(contactEdge.locator(".react-flow__edge-path")).toHaveCount(0);
|
|
});
|
|
|
|
test("FORC 的显式力方向不会随工作区旋转或镜像翻转", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
const node = await dragSymbolToCanvas(page, "amesim_forc", {
|
|
x: 440,
|
|
y: 300,
|
|
});
|
|
const direction = page.getByRole("combobox", { name: "力方向" });
|
|
await direction.selectOption("-1");
|
|
await expect(direction).toHaveValue("-1");
|
|
|
|
await page
|
|
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
|
|
.click();
|
|
await page
|
|
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
|
|
.click();
|
|
await page
|
|
.getByRole("button", { name: "水平镜像 (Shift+H)", exact: true })
|
|
.click();
|
|
await expect(direction).toHaveValue("-1");
|
|
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const raw = localStorage.getItem("system-simulation-flow:autosave");
|
|
const project = raw ? JSON.parse(raw) : null;
|
|
const force = project?.nodes?.find(
|
|
(candidate: { data?: { componentType?: string } }) =>
|
|
candidate.data?.componentType === "amesim_forc",
|
|
);
|
|
return force
|
|
? {
|
|
direction: force.data.parameters.direction,
|
|
mirrored: force.data.mirrored,
|
|
rotation: force.data.rotation,
|
|
}
|
|
: null;
|
|
}),
|
|
)
|
|
.toEqual({ direction: -1, mirrored: true, rotation: 180 });
|
|
|
|
await page.getByRole("button", { name: "生成系统 XML" }).click();
|
|
await expandSimulationConsole(page);
|
|
const xml = page.getByLabel("生成的系统 XML");
|
|
await expect(xml).toContainText('name="direction" value="-1"');
|
|
await expect(xml).not.toContainText("rotation=");
|
|
await expect(xml).not.toContainText("mirrored=");
|
|
await expect(node).toHaveClass(/selected/);
|
|
});
|
|
|
|
test("旧版 FORC 工程会迁移为默认正向且不再依赖图标旋转", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await dragSymbolToCanvas(page, "amesim_forc", { x: 440, y: 300 });
|
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
|
|
|
const downgraded = await page.evaluate(() => {
|
|
const key = Object.keys(localStorage).find((candidate) =>
|
|
candidate.startsWith("system-simulation-flow:project:"),
|
|
);
|
|
const raw = key ? localStorage.getItem(key) : null;
|
|
if (!key || !raw) {
|
|
return false;
|
|
}
|
|
const project = JSON.parse(raw) as {
|
|
nodes: Array<{
|
|
data: {
|
|
componentType: string;
|
|
modelVersion?: string;
|
|
parameters: Record<string, unknown>;
|
|
};
|
|
}>;
|
|
};
|
|
const force = project.nodes.find(
|
|
(candidate) => candidate.data.componentType === "amesim_forc",
|
|
);
|
|
if (!force) {
|
|
return false;
|
|
}
|
|
force.data.modelVersion = "0.1.0";
|
|
delete force.data.parameters.direction;
|
|
localStorage.setItem(key, JSON.stringify(project));
|
|
return true;
|
|
});
|
|
expect(downgraded).toBe(true);
|
|
|
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
|
const force = page.locator(
|
|
'.flow-canvas .react-flow__node[data-id^="amesim_forc_"]',
|
|
);
|
|
await force.locator('[data-symbol-envelope="true"]').click();
|
|
await expect(page.getByRole("combobox", { name: "力方向" })).toHaveValue("1");
|
|
await page.getByRole("button", { name: "展开仿真控制台" }).click();
|
|
await expect(
|
|
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
|
).toContainText("已升级 1 个 FORC 元件的显式力方向合同");
|
|
|
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const key = Object.keys(localStorage).find((candidate) =>
|
|
candidate.startsWith("system-simulation-flow:project:"),
|
|
);
|
|
const raw = key ? localStorage.getItem(key) : null;
|
|
const project = raw ? JSON.parse(raw) : null;
|
|
const force = project?.nodes?.find(
|
|
(candidate: { data?: { componentType?: string } }) =>
|
|
candidate.data?.componentType === "amesim_forc",
|
|
);
|
|
return {
|
|
modelVersion: force?.data?.modelVersion,
|
|
direction: force?.data?.parameters?.direction,
|
|
};
|
|
}),
|
|
)
|
|
.toEqual({ modelVersion: "0.2.0", direction: 1 });
|
|
});
|
|
|
|
test("MECMAS21 图标按摩擦和限位参数切换四种参考外观", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
const paletteState = page.locator(
|
|
'aside.palette [data-component-symbol="amesim_mecmas21"] [data-mecmas-variant]',
|
|
);
|
|
await expect(paletteState).toHaveAttribute(
|
|
"data-mecmas-variant",
|
|
"friction-limited",
|
|
);
|
|
await expect(paletteState).toHaveAttribute("data-mecmas-friction", "enabled");
|
|
await expect(paletteState).toHaveAttribute("data-mecmas-limits", "finite");
|
|
|
|
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 massOutline = symbol.locator('[data-mecmas-part="mass"]');
|
|
const envelope = node.locator('[data-symbol-envelope="true"]');
|
|
const useFriction = page.getByLabel("启用摩擦", { exact: true });
|
|
const stoptype = page.getByLabel("限位类型", { exact: true });
|
|
let fixedEnvelopeBounds: {
|
|
height: number;
|
|
width: number;
|
|
x: number;
|
|
y: number;
|
|
} | null = null;
|
|
|
|
await expectSymbolAnchors(node, [
|
|
{ portName: "port_2", x: 10.4, y: 24 },
|
|
{ portName: "port_1", x: 53.6, y: 24 },
|
|
]);
|
|
await expect(massOutline).toHaveAttribute("stroke-width", "2.5");
|
|
await expect(massOutline).toHaveCSS("stroke-width", "2.5px");
|
|
await expect(massOutline).toHaveCSS(
|
|
"vector-effect",
|
|
"non-scaling-stroke",
|
|
);
|
|
await expect(state).toHaveAttribute("transform", /scale\(0\.72\)/);
|
|
await expect(useFriction).toHaveValue("2");
|
|
await expect(stoptype).toHaveValue("1");
|
|
const fixedLayout = await node.evaluate((element) => {
|
|
const nodeBounds = element.getBoundingClientRect();
|
|
const portCenters = Array.from(
|
|
element.querySelectorAll<HTMLElement>(".port-handle"),
|
|
).map((handle) => {
|
|
const bounds = handle.getBoundingClientRect();
|
|
return {
|
|
name: handle.dataset.portName,
|
|
x: bounds.left + bounds.width / 2,
|
|
y: bounds.top + bounds.height / 2,
|
|
};
|
|
});
|
|
return {
|
|
height: nodeBounds.height,
|
|
width: nodeBounds.width,
|
|
x: nodeBounds.left,
|
|
y: nodeBounds.top,
|
|
portCenters,
|
|
};
|
|
});
|
|
|
|
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 -8 64 64");
|
|
const currentLayout = await node.evaluate((element) => {
|
|
const nodeBounds = element.getBoundingClientRect();
|
|
return {
|
|
height: nodeBounds.height,
|
|
width: nodeBounds.width,
|
|
x: nodeBounds.left,
|
|
y: nodeBounds.top,
|
|
portCenters: Array.from(
|
|
element.querySelectorAll<HTMLElement>(".port-handle"),
|
|
).map((handle) => {
|
|
const bounds = handle.getBoundingClientRect();
|
|
return {
|
|
name: handle.dataset.portName,
|
|
x: bounds.left + bounds.width / 2,
|
|
y: bounds.top + bounds.height / 2,
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
expect(Math.abs(currentLayout.width - fixedLayout.width)).toBeLessThan(0.5);
|
|
expect(Math.abs(currentLayout.height - fixedLayout.height)).toBeLessThan(0.5);
|
|
expect(Math.abs(currentLayout.x - fixedLayout.x)).toBeLessThan(0.5);
|
|
expect(Math.abs(currentLayout.y - fixedLayout.y)).toBeLessThan(0.5);
|
|
expect(currentLayout.portCenters).toEqual(fixedLayout.portCenters);
|
|
const currentEnvelopeBounds = await envelope.boundingBox();
|
|
expect(currentEnvelopeBounds).not.toBeNull();
|
|
if (fixedEnvelopeBounds === null) {
|
|
fixedEnvelopeBounds = currentEnvelopeBounds!;
|
|
} else {
|
|
expect(Math.abs(currentEnvelopeBounds!.x - fixedEnvelopeBounds.x)).toBeLessThan(
|
|
0.5,
|
|
);
|
|
expect(Math.abs(currentEnvelopeBounds!.y - fixedEnvelopeBounds.y)).toBeLessThan(
|
|
0.5,
|
|
);
|
|
expect(
|
|
Math.abs(currentEnvelopeBounds!.width - fixedEnvelopeBounds.width),
|
|
).toBeLessThan(0.5);
|
|
expect(
|
|
Math.abs(currentEnvelopeBounds!.height - fixedEnvelopeBounds.height),
|
|
).toBeLessThan(0.5);
|
|
}
|
|
const aspectRatio =
|
|
currentEnvelopeBounds!.width / currentEnvelopeBounds!.height;
|
|
expect(aspectRatio).toBeGreaterThan(0.95);
|
|
expect(aspectRatio).toBeLessThan(1.15);
|
|
if (guides) {
|
|
const [visualBounds, symbolBounds] = await Promise.all([
|
|
state.boundingBox(),
|
|
symbol.boundingBox(),
|
|
]);
|
|
expect(visualBounds).not.toBeNull();
|
|
expect(symbolBounds).not.toBeNull();
|
|
expect(visualBounds!.width / symbolBounds!.width).toBeGreaterThan(0.65);
|
|
expect(visualBounds!.width / symbolBounds!.width).toBeLessThan(0.75);
|
|
expect(visualBounds!.height / symbolBounds!.height).toBeGreaterThan(0.65);
|
|
expect(visualBounds!.height / symbolBounds!.height).toBeLessThan(0.75);
|
|
}
|
|
};
|
|
|
|
await expectVariant({
|
|
friction: "enabled",
|
|
guides: true,
|
|
limits: "finite",
|
|
lowerGuide: "finite",
|
|
variant: "friction-limited",
|
|
});
|
|
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,
|
|
};
|
|
});
|
|
|
|
await stoptype.selectOption("4");
|
|
await expectVariant({
|
|
friction: "enabled",
|
|
guides: true,
|
|
limits: "unlimited",
|
|
lowerGuide: "unlimited",
|
|
variant: "friction-unlimited",
|
|
});
|
|
|
|
await useFriction.selectOption("1");
|
|
await expectVariant({
|
|
friction: "disabled",
|
|
guides: false,
|
|
limits: "unlimited",
|
|
lowerGuide: "none",
|
|
variant: "plain-unlimited",
|
|
});
|
|
|
|
await stoptype.selectOption("2");
|
|
await expectVariant({
|
|
friction: "disabled",
|
|
guides: true,
|
|
limits: "finite",
|
|
lowerGuide: "finite",
|
|
variant: "plain-limited",
|
|
});
|
|
|
|
await useFriction.selectOption("2");
|
|
await expectVariant({
|
|
friction: "enabled",
|
|
guides: true,
|
|
limits: "finite",
|
|
lowerGuide: "finite",
|
|
variant: "friction-limited",
|
|
});
|
|
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 expect(piston.locator(".sim-node")).toHaveAttribute(
|
|
"data-node-layout-tier",
|
|
"standard",
|
|
);
|
|
const pistonBounds = await piston.boundingBox();
|
|
expect(pistonBounds).not.toBeNull();
|
|
expect(Math.abs(pistonBounds!.width - 132)).toBeLessThan(1);
|
|
expect(Math.abs(pistonBounds!.height - 112)).toBeLessThan(1);
|
|
await expectSymbolAnchors(piston, [
|
|
{ portName: "port_1", x: 25.5, y: 52.935 },
|
|
{ portName: "port_3", x: 2, y: 0.179 },
|
|
{ portName: "port_2", x: 2, y: 24.016 },
|
|
{ portName: "port_4", x: 62, y: 0.179 },
|
|
{ portName: "port_5", x: 62, y: 24.016 },
|
|
]);
|
|
await expect(
|
|
piston.locator('.port-handle[data-port-name="port_1"]'),
|
|
).toHaveClass(/react-flow__handle-bottom/);
|
|
const pnrp17Svg = piston.locator(
|
|
'[data-component-symbol="amesim_pnrp17"] svg',
|
|
);
|
|
await expect(pnrp17Svg.locator('[stroke-width="2.1"]')).toHaveCount(8);
|
|
await expect(pnrp17Svg.locator('[stroke-width="1.9"]')).toHaveCount(2);
|
|
const arrowPairs = pnrp17Svg.locator(
|
|
'[data-pnrp17-part="chamber-arrows"]',
|
|
);
|
|
await expect(arrowPairs).toHaveCount(2);
|
|
for (let pairIndex = 0; pairIndex < 2; pairIndex += 1) {
|
|
const pair = arrowPairs.nth(pairIndex);
|
|
const leftArrow = pair.locator('[data-pnrp17-arrow="left"]');
|
|
const rightArrow = pair.locator('[data-pnrp17-arrow="right"]');
|
|
await expect(leftArrow.locator("path").nth(0)).toHaveAttribute(
|
|
"d",
|
|
"M9.1 0H3.9",
|
|
);
|
|
await expect(rightArrow.locator("path").nth(0)).toHaveAttribute(
|
|
"d",
|
|
"M9.1 0H3.9",
|
|
);
|
|
await expect(leftArrow.locator("path").nth(1)).toHaveAttribute(
|
|
"d",
|
|
"M3.9 -2.5 0 0 3.9 2.5Z",
|
|
);
|
|
await expect(rightArrow.locator("path").nth(1)).toHaveAttribute(
|
|
"d",
|
|
"M3.9 -2.5 0 0 3.9 2.5Z",
|
|
);
|
|
await expect(leftArrow).toHaveAttribute("transform", "translate(18.2 0)");
|
|
await expect(rightArrow).toHaveAttribute(
|
|
"transform",
|
|
"translate(38.8 0) scale(-1 1)",
|
|
);
|
|
}
|
|
const upperShell = pnrp17Svg.locator(
|
|
'[data-pnrp17-part="upper-shell"]',
|
|
);
|
|
await expect(upperShell).toHaveAttribute(
|
|
"d",
|
|
"M13.4 1.1H51.4V3h4.1v2.9h-4.1v1.9H18.2V21.1H3.3V7.3H13.4V5.9H9.3V3h4.1Z",
|
|
);
|
|
await expect(
|
|
pnrp17Svg.locator('[data-pnrp17-part="upper-left-wall"]'),
|
|
).toHaveAttribute("width", "2.9");
|
|
await expect(
|
|
pnrp17Svg.locator('[data-pnrp17-part="lower-left-shell"]'),
|
|
).toHaveAttribute("d", "M3.3 27H18.2V40.7H20V46.6H3.3Z");
|
|
await expect(
|
|
pnrp17Svg.locator('[data-pnrp17-part="lower-left-wall"]'),
|
|
).toHaveAttribute("width", "2.9");
|
|
const pistonSleeve = pnrp17Svg.locator(
|
|
'[data-pnrp17-part="piston-sleeve"]',
|
|
);
|
|
await expect(pistonSleeve).toHaveAttribute("x", "40.8");
|
|
await expect(pistonSleeve).toHaveAttribute("width", "7.7");
|
|
expect(
|
|
await pnrp17Svg
|
|
.locator('[data-pnrp17-part="symbol"] > [data-pnrp17-part]')
|
|
.evaluateAll((elements) =>
|
|
elements.map((element) => element.getAttribute("data-pnrp17-part")),
|
|
),
|
|
).toEqual([
|
|
"upper-shell",
|
|
"upper-left-wall",
|
|
"lower-left-shell",
|
|
"lower-left-wall",
|
|
"lower-base",
|
|
"piston-sleeve",
|
|
"piston-face",
|
|
"horizontal-rod-left",
|
|
"horizontal-rod-right",
|
|
"horizontal-extensions",
|
|
"bottom-port",
|
|
"chamber-arrows",
|
|
"chamber-arrows",
|
|
]);
|
|
const bottomPort = pnrp17Svg.locator(
|
|
'[data-pnrp17-part="bottom-port"]',
|
|
);
|
|
await expect(bottomPort).toHaveAttribute("d", "M25.5 43.4V48");
|
|
await expect(bottomPort).toHaveAttribute("fill", "none");
|
|
await expect(
|
|
pnrp17Svg.locator('[data-pnrp17-part="symbol"]'),
|
|
).toHaveAttribute("transform", /scale\(1 1\.21\)/);
|
|
await expect(
|
|
pnrp17Svg.locator('[data-pnrp17-part="horizontal-extensions"]'),
|
|
).toHaveAttribute(
|
|
"d",
|
|
"M2 4.4h7.3m46.2 0H62M2 24.1h7.3m46.2 0H62",
|
|
);
|
|
const renderedStrokes = await pnrp17Svg.evaluate((element) => {
|
|
const graphics = Array.from(
|
|
element.querySelectorAll<SVGGraphicsElement>("path, rect"),
|
|
).filter((graphic) => getComputedStyle(graphic).stroke !== "none");
|
|
return {
|
|
independent: graphics.every(
|
|
(graphic) =>
|
|
getComputedStyle(graphic).vectorEffect === "non-scaling-stroke",
|
|
),
|
|
widths: graphics.reduce<Record<string, number>>((counts, graphic) => {
|
|
const width = getComputedStyle(graphic).strokeWidth;
|
|
counts[width] = (counts[width] ?? 0) + 1;
|
|
return counts;
|
|
}, {}),
|
|
};
|
|
});
|
|
expect(renderedStrokes.independent).toBe(true);
|
|
expect(renderedStrokes.widths).toEqual({ "1.9px": 8, "2.1px": 8 });
|
|
});
|
|
|
|
test("PN3NODE2 与 P4NODE2 使用参考图直线汇流结构且粗线对应 port_2", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const cases = [
|
|
{
|
|
anchors: [
|
|
{ portName: "port_1", x: 36.6, y: 11.2 },
|
|
{ portName: "port_2", x: 24.6, y: 24 },
|
|
{ portName: "port_3", x: 36.6, y: 36.8 },
|
|
],
|
|
center: { x: 36.6, y: 24 },
|
|
finePath: "M36.6 11.2V36.8M24.6 24H27.5",
|
|
modelType: "amesim_pn3node2",
|
|
positions: {
|
|
port_1: "top",
|
|
port_2: "left",
|
|
port_3: "bottom",
|
|
},
|
|
thickPath: "M27.5 24H36.6",
|
|
},
|
|
{
|
|
anchors: [
|
|
{ portName: "port_1", x: 32, y: 11.2 },
|
|
{ portName: "port_2", x: 19.2, y: 24 },
|
|
{ portName: "port_3", x: 44.8, y: 24 },
|
|
{ portName: "port_4", x: 32, y: 36.8 },
|
|
],
|
|
center: { x: 32, y: 24 },
|
|
finePath: "M32 11.2V36.8M32 24H44.8M19.2 24H22.1",
|
|
modelType: "amesim_p4node2",
|
|
positions: {
|
|
port_1: "top",
|
|
port_2: "left",
|
|
port_3: "right",
|
|
port_4: "bottom",
|
|
},
|
|
thickPath: "M22.1 24H32",
|
|
},
|
|
] as const;
|
|
|
|
for (const [index, item] of cases.entries()) {
|
|
const node = await dragSymbolToCanvas(page, item.modelType, {
|
|
x: 300 + index * 300,
|
|
y: 300,
|
|
});
|
|
await expect(node.locator(".sim-node")).toHaveAttribute(
|
|
"data-node-layout-tier",
|
|
"small",
|
|
);
|
|
const bounds = await node.boundingBox();
|
|
expect(bounds).not.toBeNull();
|
|
expect(Math.abs(bounds!.width - 116)).toBeLessThan(1);
|
|
expect(Math.abs(bounds!.height - 96)).toBeLessThan(1);
|
|
await expectSymbolAnchors(node, [...item.anchors]);
|
|
|
|
for (const [portName, position] of Object.entries(item.positions)) {
|
|
await expect(
|
|
node.locator(`.port-handle[data-port-name="${portName}"]`),
|
|
).toHaveClass(new RegExp(`react-flow__handle-${position}`));
|
|
}
|
|
|
|
const svg = node.locator(
|
|
`[data-component-symbol="${item.modelType}"] svg`,
|
|
);
|
|
const fineBranches = svg.locator('[data-junction-part="fine-branches"]');
|
|
await expect(fineBranches).toHaveAttribute("d", item.finePath);
|
|
await expect(fineBranches).toHaveAttribute("stroke-width", "1.5");
|
|
const collector = svg.locator('[data-junction-part="collector-branch"]');
|
|
await expect(collector).toHaveAttribute("d", item.thickPath);
|
|
await expect(collector).toHaveAttribute("data-port-name", "port_2");
|
|
await expect(collector).toHaveAttribute("stroke-width", "4");
|
|
const center = svg.locator('[data-junction-part="center"]');
|
|
await expect(center).toHaveAttribute("cx", String(item.center.x));
|
|
await expect(center).toHaveAttribute("cy", String(item.center.y));
|
|
await expect(center).toHaveAttribute("r", "2.75");
|
|
await expect(center).toHaveAttribute("fill", "#8b134f");
|
|
|
|
const geometry = await svg.evaluate((element: SVGSVGElement) => {
|
|
const graphicBounds = element.getBBox();
|
|
const viewBox = element.viewBox.baseVal;
|
|
return {
|
|
occupancy:
|
|
Math.max(graphicBounds.width, graphicBounds.height) /
|
|
Math.max(viewBox.width, viewBox.height),
|
|
};
|
|
});
|
|
expect(geometry.occupancy).toBeCloseTo(0.4, 4);
|
|
}
|
|
});
|
|
|
|
test("PNL00R 与 PNL0001-0003 使用 standard 画布和 60% 工作区占比", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const modelTypes = [
|
|
"amesim_pnl00r",
|
|
"amesim_pnl0001",
|
|
"amesim_pnl0002",
|
|
"amesim_pnl0003",
|
|
] as const;
|
|
|
|
for (const [index, modelType] of modelTypes.entries()) {
|
|
const paletteSymbol = page.locator(
|
|
`aside.palette [data-component-symbol="${modelType}"]`,
|
|
);
|
|
await expect(paletteSymbol).toHaveAttribute("data-symbol-size-tier", "standard");
|
|
await expect(paletteSymbol).toHaveAttribute("data-palette-occupancy", "0.9");
|
|
|
|
const node = await dragSymbolToCanvas(page, modelType, {
|
|
x: 230 + (index % 2) * 330,
|
|
y: 220 + Math.floor(index / 2) * 260,
|
|
});
|
|
await expect(node.locator(".sim-node")).toHaveAttribute(
|
|
"data-node-layout-tier",
|
|
"standard",
|
|
);
|
|
const bounds = await node.boundingBox();
|
|
expect(bounds).not.toBeNull();
|
|
expect(Math.abs(bounds!.width - 132)).toBeLessThan(1);
|
|
expect(Math.abs(bounds!.height - 112)).toBeLessThan(1);
|
|
await expectSymbolAnchors(node, [
|
|
{ portName: "port_1", x: 12.8, y: 24 },
|
|
{ portName: "port_2", x: 51.2, y: 24 },
|
|
]);
|
|
|
|
const symbol = node.locator(`[data-component-symbol="${modelType}"]`);
|
|
await expect(symbol).toHaveAttribute("data-symbol-view-box", "0 -8 64 64");
|
|
const content = symbol.locator('[data-pipe-network-content="true"]');
|
|
await expect(content).toHaveAttribute(
|
|
"transform",
|
|
"translate(32 24) scale(0.64) translate(-32 -24)",
|
|
);
|
|
const geometry = await content.evaluate((element: SVGGElement) => {
|
|
const contentBounds = element.getBBox();
|
|
const matrix = element.transform.baseVal.consolidate()?.matrix;
|
|
const scale = matrix?.a ?? 1;
|
|
return {
|
|
occupancy:
|
|
(Math.max(contentBounds.width, contentBounds.height) * scale) / 64,
|
|
};
|
|
});
|
|
expect(geometry.occupancy).toBeCloseTo(0.6, 4);
|
|
if (modelType === "amesim_pnl0001") {
|
|
const causalLabels = symbol.locator("text");
|
|
await expect(causalLabels).toHaveText(["R", "C"]);
|
|
const labelPositions = await causalLabels.evaluateAll((labels) =>
|
|
labels.map((label) => Number(label.getAttribute("x"))),
|
|
);
|
|
expect(labelPositions[0]).toBeLessThan(labelPositions[1]);
|
|
}
|
|
}
|
|
});
|
|
|
|
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,
|
|
});
|
|
const multiPortCount = page.getByRole("textbox", { name: "右侧端口数" });
|
|
await multiPortCount.fill("8");
|
|
await multiPortCount.press("Enter");
|
|
await expect(multiPortNode.locator(".port-handle")).toHaveCount(9);
|
|
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("批量选中时隐藏已连接接口编号并保留未连接接口编号", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const firstNode = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
|
x: 320,
|
|
y: 280,
|
|
});
|
|
const secondNode = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
|
x: 620,
|
|
y: 280,
|
|
});
|
|
const firstConnectedPort = firstNode.locator(
|
|
'.port-handle[data-port-name="port_2"]',
|
|
);
|
|
const secondConnectedPort = secondNode.locator(
|
|
'.port-handle[data-port-name="port_1"]',
|
|
);
|
|
|
|
await firstConnectedPort.click();
|
|
await secondConnectedPort.click();
|
|
const connectedEdge = page.locator(".flow-canvas .react-flow__edge");
|
|
await expect(connectedEdge).toHaveCount(1);
|
|
await expect(connectedEdge.locator(".react-flow__edge-path")).toHaveCount(1);
|
|
await expect(firstConnectedPort).toHaveAttribute("data-connected", "true");
|
|
await expect(secondConnectedPort).toHaveAttribute("data-connected", "true");
|
|
|
|
const pane = page.locator(".flow-canvas .react-flow__pane");
|
|
const [paneBounds, firstEnvelopeBounds, secondEnvelopeBounds] =
|
|
await Promise.all([
|
|
pane.boundingBox(),
|
|
firstNode.locator('[data-symbol-envelope="true"]').boundingBox(),
|
|
secondNode.locator('[data-symbol-envelope="true"]').boundingBox(),
|
|
]);
|
|
expect(paneBounds).not.toBeNull();
|
|
expect(firstEnvelopeBounds).not.toBeNull();
|
|
expect(secondEnvelopeBounds).not.toBeNull();
|
|
|
|
await pane.click({ position: { x: 20, y: 20 } });
|
|
const selectionStart = {
|
|
x: Math.max(
|
|
paneBounds!.x + 4,
|
|
Math.min(firstEnvelopeBounds!.x, secondEnvelopeBounds!.x) - 14,
|
|
),
|
|
y: Math.max(
|
|
paneBounds!.y + 4,
|
|
Math.min(firstEnvelopeBounds!.y, secondEnvelopeBounds!.y) - 14,
|
|
),
|
|
};
|
|
const selectionEnd = {
|
|
x: Math.min(
|
|
paneBounds!.x + paneBounds!.width - 4,
|
|
Math.max(
|
|
firstEnvelopeBounds!.x + firstEnvelopeBounds!.width,
|
|
secondEnvelopeBounds!.x + secondEnvelopeBounds!.width,
|
|
) + 14,
|
|
),
|
|
y: Math.min(
|
|
paneBounds!.y + paneBounds!.height - 4,
|
|
Math.max(
|
|
firstEnvelopeBounds!.y + firstEnvelopeBounds!.height,
|
|
secondEnvelopeBounds!.y + secondEnvelopeBounds!.height,
|
|
) + 14,
|
|
),
|
|
};
|
|
await page.mouse.move(selectionStart.x, selectionStart.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(selectionEnd.x, selectionEnd.y, { steps: 12 });
|
|
await page.mouse.up();
|
|
|
|
await expect(firstNode).toHaveClass(/selected/);
|
|
await expect(secondNode).toHaveClass(/selected/);
|
|
await expectPortIndexLabels(firstNode, { port_1: "1" });
|
|
await expectPortIndexLabels(secondNode, { port_2: "2" });
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise<void>((resolve) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
),
|
|
);
|
|
await expect(connectedEdge).not.toHaveClass(/selected|editor-edge-selected/);
|
|
|
|
await pane.click({ position: { x: 20, y: 20 } });
|
|
await firstNode.locator('[data-symbol-envelope="true"]').click();
|
|
await expect(firstNode).toHaveClass(/selected/);
|
|
await expect(secondNode).not.toHaveClass(/selected/);
|
|
await expectPortIndexLabels(firstNode, { port_1: "1", port_2: "2" });
|
|
await expect(secondNode.locator(".port-index-label")).toHaveCount(0);
|
|
});
|
|
|
|
test("缺失或错误工程版本、字符串端口和缺失 Handle 均拒绝恢复", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
const invalidVariants = [
|
|
"missing-version",
|
|
"wrong-version",
|
|
"string-port",
|
|
"missing-handle",
|
|
] as const;
|
|
|
|
for (const variant of invalidVariants) {
|
|
await page.evaluate((invalidVariant) => {
|
|
const port = {
|
|
name: "port_1",
|
|
kind: "physical",
|
|
domain: "pneumatic",
|
|
nominalRole: "bidirectional",
|
|
positiveFlowDirection: "intoComponent",
|
|
side: "left",
|
|
};
|
|
const makeNode = (id: string, x: number) => ({
|
|
id,
|
|
type: "simulationComponent",
|
|
position: { x, y: 160 },
|
|
data: {
|
|
label: id,
|
|
componentType: "amesim_pnpl01",
|
|
modelType: "amesim_pnpl01",
|
|
ports: [{ ...port }],
|
|
parameters: {},
|
|
parameterUnits: {},
|
|
parameterScientificNotation: {},
|
|
rotation: 0,
|
|
mirrored: false,
|
|
},
|
|
});
|
|
const project: Record<string, any> = {
|
|
projectSchemaVersion: 1,
|
|
name: `invalid-${invalidVariant}`,
|
|
nodes: [
|
|
makeNode("amesim_pnpl01_1", 180),
|
|
makeNode("amesim_pnpl01_2", 420),
|
|
],
|
|
edges: [
|
|
{
|
|
id: "edge-1",
|
|
source: "amesim_pnpl01_1",
|
|
sourceHandle: "port_1",
|
|
target: "amesim_pnpl01_2",
|
|
targetHandle: "port_1",
|
|
data: { isContactEdge: false },
|
|
},
|
|
],
|
|
simulation: {
|
|
t_start: 0,
|
|
t_stop: 2,
|
|
step: 0.1,
|
|
max_step: 0.005,
|
|
method: "BDF",
|
|
},
|
|
};
|
|
if (invalidVariant === "missing-version") {
|
|
delete project.projectSchemaVersion;
|
|
} else if (invalidVariant === "wrong-version") {
|
|
project.projectSchemaVersion = 2;
|
|
} else if (invalidVariant === "string-port") {
|
|
project.nodes[0].data.ports = ["port_1"];
|
|
} else {
|
|
project.edges[0].sourceHandle = null;
|
|
}
|
|
window.localStorage.setItem(
|
|
"system-simulation-flow:autosave",
|
|
JSON.stringify(project),
|
|
);
|
|
}, variant);
|
|
await page.reload();
|
|
await expect(page.getByRole("button", { name: "恢复", exact: true })).toHaveCount(0);
|
|
}
|
|
});
|
|
|
|
test("恢复工程时采用当前目录端口并明确丢弃已删除端口的连接", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
const firstNode = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 300,
|
|
y: 260,
|
|
});
|
|
const secondNode = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 600,
|
|
y: 260,
|
|
});
|
|
await firstNode.locator('[data-port-name="port_1"]').click();
|
|
await secondNode.locator('[data-port-name="port_1"]').click();
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
|
const changed = await page.evaluate(() => {
|
|
const key = Object.keys(window.localStorage).find((candidate) =>
|
|
candidate.startsWith("system-simulation-flow:project:"),
|
|
);
|
|
const raw = key ? window.localStorage.getItem(key) : null;
|
|
if (!key || !raw) {
|
|
return false;
|
|
}
|
|
const project = JSON.parse(raw) as {
|
|
nodes?: Array<{ data?: { ports?: Array<Record<string, unknown>> } }>;
|
|
edges?: Array<{
|
|
id?: string;
|
|
sourceHandle?: string;
|
|
targetHandle?: string;
|
|
}>;
|
|
};
|
|
project.nodes?.forEach((node) => {
|
|
if (node.data?.ports?.[0]) {
|
|
node.data.ports[0].name = "legacy_port";
|
|
}
|
|
});
|
|
const edge = project.edges?.[0];
|
|
if (!edge) {
|
|
return false;
|
|
}
|
|
edge.id = "legacy-edge";
|
|
edge.sourceHandle = "legacy_port";
|
|
edge.targetHandle = "legacy_port";
|
|
window.localStorage.setItem(key, JSON.stringify(project));
|
|
return true;
|
|
});
|
|
expect(changed).toBe(true);
|
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
|
const nodes = page.locator(".flow-canvas .react-flow__node");
|
|
await expect(nodes).toHaveCount(2);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
|
await expect(nodes.locator('[data-port-name="legacy_port"]')).toHaveCount(0);
|
|
await expect(nodes.first().locator('[data-port-name="port_1"]')).toBeVisible();
|
|
await page.getByRole("button", { name: "展开仿真控制台" }).click();
|
|
await expect(
|
|
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
|
).toContainText(
|
|
"已丢弃失效连接 legacy-edge:端口 amesim_pnpl01_1.legacy_port、amesim_pnpl01_2.legacy_port 不在当前组件目录中",
|
|
);
|
|
});
|
|
|
|
test("AMESim canvas nodes use icon anchors and highlight only compatible free ports", async ({
|
|
page,
|
|
}) => {
|
|
const pageErrors: string[] = [];
|
|
page.on("pageerror", (error) => pageErrors.push(error.message));
|
|
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: 12.8, y: 24 },
|
|
{ portName: "port_2", x: 51.2, y: 24 },
|
|
];
|
|
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
|
|
await expectSymbolAnchors(signalOrifice, [
|
|
{ portName: "res", x: 27.35, y: 43.2 },
|
|
{ portName: "port_2", x: 12.8, y: 24 },
|
|
{ portName: "port_3", x: 51.2, y: 24 },
|
|
]);
|
|
|
|
await firstOrifice.locator('[data-symbol-envelope="true"]').click();
|
|
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"]',
|
|
);
|
|
|
|
for (const port of [firstPhysicalOutput, signalOutput]) {
|
|
await expect(port).toHaveCSS("width", "6px");
|
|
await expect(port).toHaveCSS("height", "6px");
|
|
await expect(port).toHaveCSS("border-top-width", "0px");
|
|
await expect(port.locator(".port-handle-glyph-shape")).toHaveCSS(
|
|
"stroke-width",
|
|
"1px",
|
|
);
|
|
const hitAreaSize = await port.evaluate((element) => {
|
|
const visualStyle = getComputedStyle(element);
|
|
const hitAreaStyle = getComputedStyle(element, "::before");
|
|
return {
|
|
height:
|
|
Number.parseFloat(visualStyle.height) -
|
|
Number.parseFloat(hitAreaStyle.top) -
|
|
Number.parseFloat(hitAreaStyle.bottom),
|
|
width:
|
|
Number.parseFloat(visualStyle.width) -
|
|
Number.parseFloat(hitAreaStyle.left) -
|
|
Number.parseFloat(hitAreaStyle.right),
|
|
};
|
|
});
|
|
expect(Math.abs(hitAreaSize.width - 15)).toBeLessThan(0.01);
|
|
expect(Math.abs(hitAreaSize.height - 15)).toBeLessThan(0.01);
|
|
const bounds = await port.boundingBox();
|
|
expect(bounds).not.toBeNull();
|
|
const hitTarget = await page.evaluate(
|
|
({ x, y }) =>
|
|
document
|
|
.elementFromPoint(x, y)
|
|
?.closest(".port-handle")
|
|
?.getAttribute("data-port-name") ?? null,
|
|
{
|
|
x: bounds!.x - 3,
|
|
y: bounds!.y + bounds!.height / 2,
|
|
},
|
|
);
|
|
expect(hitTarget).toBe(await port.getAttribute("data-port-name"));
|
|
}
|
|
const physicalPortShape = await firstPhysicalOutput.evaluate((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
const style = getComputedStyle(element);
|
|
return {
|
|
borderRadius: style.borderTopLeftRadius,
|
|
height: bounds.height,
|
|
width: bounds.width,
|
|
};
|
|
});
|
|
expect(Math.abs(physicalPortShape.width - physicalPortShape.height)).toBeLessThan(
|
|
0.01,
|
|
);
|
|
expect(physicalPortShape.borderRadius).toBe("50%");
|
|
const physicalCircle = firstPhysicalOutput.locator(
|
|
'[data-port-glyph-shape="circle"]',
|
|
);
|
|
await expect(physicalCircle).toHaveAttribute("cx", "3");
|
|
await expect(physicalCircle).toHaveAttribute("cy", "3");
|
|
await expect(physicalCircle).toHaveAttribute("r", "2.5");
|
|
await expect(physicalCircle).not.toHaveAttribute(
|
|
"vector-effect",
|
|
"non-scaling-stroke",
|
|
);
|
|
|
|
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();
|
|
await expect(physicalEdge.locator(".edge-endpoint-blocker")).toHaveCount(0);
|
|
await expect(physicalEdge.locator(".react-flow__edge-interaction")).toHaveCount(1);
|
|
await expect(physicalEdge).not.toHaveClass(/editor-edge-contact/);
|
|
|
|
await signalOutput.click();
|
|
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
|
|
await clickLocatorCenter(page, firstPhysicalOutput);
|
|
await expectNoActivePortConnection(page);
|
|
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(physicalEdge).not.toHaveClass(/selected|editor-edge-selected/);
|
|
|
|
await clickLocatorCenter(page, compatiblePhysicalPort);
|
|
await expectNoActivePortConnection(page);
|
|
await expect(physicalEdge).not.toHaveClass(/selected|editor-edge-selected/);
|
|
|
|
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");
|
|
await expect(signalOutput).toHaveCSS("opacity", "0");
|
|
await expect(signalInput).toHaveCSS("opacity", "0");
|
|
await expect(signalOutput).toHaveCSS("pointer-events", "none");
|
|
await expect(signalInput).toHaveCSS("pointer-events", "none");
|
|
|
|
const renderedEdges = page.locator(".flow-canvas .react-flow__edge");
|
|
const clickEdgeMiddle = async (edgeIndex: number) => {
|
|
const point = await renderedEdges
|
|
.nth(edgeIndex)
|
|
.locator(".react-flow__edge-interaction")
|
|
.evaluate((path: SVGPathElement) => {
|
|
const localPoint = 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(
|
|
localPoint.x,
|
|
localPoint.y,
|
|
).matrixTransform(matrix);
|
|
return { x: screenPoint.x, y: screenPoint.y };
|
|
});
|
|
await page.mouse.click(point.x, point.y);
|
|
};
|
|
|
|
await clickEdgeMiddle(0);
|
|
await expect(renderedEdges).toHaveCount(2);
|
|
await expect(renderedEdges.nth(0)).toHaveClass(
|
|
/selected|editor-edge-selected/,
|
|
);
|
|
await clickEdgeMiddle(1);
|
|
await expect(renderedEdges).toHaveCount(2);
|
|
await expect(renderedEdges.nth(1)).toHaveClass(
|
|
/selected|editor-edge-selected/,
|
|
);
|
|
await expect(page.getByText("System Simulation Flow", { exact: true })).toBeVisible();
|
|
expect(pageErrors).toEqual([]);
|
|
});
|
|
|
|
test("端口图标随画布缩小且物理端口始终保持标准圆形", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
const node = await dragSymbolToCanvas(page, "amesim_lmechn1", {
|
|
x: 560,
|
|
y: 360,
|
|
});
|
|
const portCount = page.getByRole("textbox", { name: "右侧端口数" });
|
|
await portCount.fill("8");
|
|
await portCount.press("Enter");
|
|
await dragSymbolToCanvas(page, "amesim_step0", {
|
|
x: 780,
|
|
y: 260,
|
|
});
|
|
|
|
const physicalPorts = node.locator(".port-handle-physical");
|
|
await expect(physicalPorts).toHaveCount(9);
|
|
const scalablePorts = page.locator(".flow-canvas .port-handle");
|
|
await expect(scalablePorts).toHaveCount(10);
|
|
const viewport = page.locator(".flow-canvas .react-flow__viewport");
|
|
const readViewportZoom = () =>
|
|
viewport.evaluate((element) => {
|
|
const values =
|
|
getComputedStyle(element)
|
|
.transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)
|
|
?.map(Number) ?? [];
|
|
return values[0] ?? 1;
|
|
});
|
|
const readPortShapes = async () => {
|
|
const shapes = await scalablePorts.evaluateAll((elements) =>
|
|
elements.map((element) => {
|
|
const handleBounds = element.getBoundingClientRect();
|
|
const glyph = element.querySelector<SVGSVGElement>(".port-handle-glyph");
|
|
const glyphBounds = glyph?.getBoundingClientRect();
|
|
const glyphShape = element.querySelector<SVGGraphicsElement>(
|
|
".port-handle-glyph-shape",
|
|
);
|
|
const circle = element.querySelector<SVGCircleElement>(
|
|
'[data-port-glyph-shape="circle"]',
|
|
);
|
|
const matrix = glyphShape?.getScreenCTM();
|
|
return {
|
|
borderRadius: getComputedStyle(element).borderTopLeftRadius,
|
|
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,
|
|
handleHeight: handleBounds.height,
|
|
handleWidth: handleBounds.width,
|
|
physical: element.classList.contains("port-handle-physical"),
|
|
scaleX: matrix ? Math.hypot(matrix.a, matrix.b) : null,
|
|
scaleY: matrix ? Math.hypot(matrix.c, matrix.d) : null,
|
|
};
|
|
}),
|
|
);
|
|
for (const shape of shapes) {
|
|
expect(Math.abs(shape.handleWidth - 6)).toBeLessThan(0.05);
|
|
expect(Math.abs(shape.handleHeight - 6)).toBeLessThan(0.05);
|
|
expect(shape.glyphWidth).not.toBeNull();
|
|
expect(shape.glyphHeight).not.toBeNull();
|
|
expect(Math.abs(shape.glyphWidth! - shape.glyphHeight!)).toBeLessThan(0.01);
|
|
if (shape.physical) {
|
|
expect(shape.borderRadius).toBe("50%");
|
|
expect(shape.circle).toEqual({ cx: 3, cy: 3, r: 2.5 });
|
|
} else {
|
|
expect(shape.borderRadius).toBe("1.5px");
|
|
expect(shape.circle).toBeNull();
|
|
}
|
|
expect(shape.scaleX).not.toBeNull();
|
|
expect(shape.scaleY).not.toBeNull();
|
|
expect(Math.abs(shape.scaleX! - shape.scaleY!)).toBeLessThan(0.0001);
|
|
}
|
|
return shapes;
|
|
};
|
|
const initialZoom = await readViewportZoom();
|
|
const initialShapes = await readPortShapes();
|
|
for (const shape of initialShapes) {
|
|
expect(Math.abs(shape.glyphWidth! - 6 * initialZoom)).toBeLessThan(0.05);
|
|
expect(Math.abs(shape.glyphHeight! - 6 * initialZoom)).toBeLessThan(0.05);
|
|
}
|
|
|
|
const pane = page.locator(".flow-canvas .react-flow__pane");
|
|
const paneBounds = await pane.boundingBox();
|
|
expect(paneBounds).not.toBeNull();
|
|
await page.mouse.move(
|
|
paneBounds!.x + paneBounds!.width / 2,
|
|
paneBounds!.y + paneBounds!.height / 2,
|
|
);
|
|
await page.keyboard.down("Control");
|
|
await page.mouse.wheel(0, 720);
|
|
await page.keyboard.up("Control");
|
|
await expect
|
|
.poll(async () => (await readViewportZoom()) / initialZoom)
|
|
.toBeLessThan(0.7);
|
|
const zoomedZoom = await readViewportZoom();
|
|
const zoomedShapes = await readPortShapes();
|
|
const zoomRatio = zoomedZoom / initialZoom;
|
|
for (const [index, shape] of zoomedShapes.entries()) {
|
|
expect(Math.abs(shape.glyphWidth! - 6 * zoomedZoom)).toBeLessThan(0.1);
|
|
expect(Math.abs(shape.glyphHeight! - 6 * zoomedZoom)).toBeLessThan(0.1);
|
|
expect(
|
|
Math.abs(shape.glyphWidth! / initialShapes[index].glyphWidth! - zoomRatio),
|
|
).toBeLessThan(0.02);
|
|
}
|
|
});
|
|
|
|
test("放大画面后接触吸附仍保留足够的画布判定半径", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
|
x: 410,
|
|
y: 300,
|
|
});
|
|
const boundary = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 540,
|
|
y: 300,
|
|
});
|
|
const pane = page.locator(".flow-canvas .react-flow__pane");
|
|
const paneBounds = await pane.boundingBox();
|
|
expect(paneBounds).not.toBeNull();
|
|
await page.mouse.move(
|
|
paneBounds!.x + paneBounds!.width / 2,
|
|
paneBounds!.y + paneBounds!.height / 2,
|
|
);
|
|
await page.keyboard.down("Control");
|
|
await page.mouse.wheel(0, -960);
|
|
await page.keyboard.up("Control");
|
|
const viewport = page.locator(".flow-canvas .react-flow__viewport");
|
|
const readZoom = () =>
|
|
viewport.evaluate((element) => {
|
|
const values =
|
|
getComputedStyle(element)
|
|
.transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)
|
|
?.map(Number) ?? [];
|
|
return values[0] ?? 1;
|
|
});
|
|
await expect.poll(readZoom).toBeGreaterThan(1.5);
|
|
|
|
const firstPort = firstOrifice.locator(
|
|
'.port-handle[data-port-name="port_2"]',
|
|
);
|
|
const secondPort = boundary.locator(
|
|
'.port-handle[data-port-name="port_1"]',
|
|
);
|
|
await dragNodePortNearTarget(
|
|
page,
|
|
boundary,
|
|
secondPort,
|
|
firstPort,
|
|
12,
|
|
);
|
|
expect(await distanceBetween(firstPort, secondPort)).toBeLessThan(20);
|
|
await expect(firstPort).toHaveAttribute(
|
|
"data-connection-state",
|
|
"compatible",
|
|
);
|
|
await expect(secondPort).toHaveAttribute(
|
|
"data-connection-state",
|
|
"origin",
|
|
);
|
|
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(10);
|
|
await page.mouse.up();
|
|
|
|
await expect
|
|
.poll(() => distanceBetween(firstPort, secondPort))
|
|
.toBeLessThan(2);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
|
});
|
|
|
|
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,
|
|
12,
|
|
);
|
|
await expect(firstPort).toHaveAttribute("data-connection-state", "idle");
|
|
await expect(secondPort).toHaveAttribute("data-connection-state", "idle");
|
|
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(10);
|
|
await page.mouse.up();
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
|
|
|
await dragNodePortNearTarget(
|
|
page,
|
|
secondOrifice,
|
|
secondPort,
|
|
firstPort,
|
|
8,
|
|
);
|
|
await expect(firstPort).toHaveAttribute(
|
|
"data-connection-state",
|
|
"compatible",
|
|
);
|
|
await expect(secondPort).toHaveAttribute(
|
|
"data-connection-state",
|
|
"origin",
|
|
);
|
|
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(6);
|
|
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 contactEdge = page.locator(".flow-canvas .react-flow__edge");
|
|
await expect(page.locator(".flow-canvas .contact-edge-hit")).toHaveCount(0);
|
|
await expect(page.locator(".flow-canvas .contact-edge-marker")).toHaveCount(0);
|
|
await expect(
|
|
page.getByRole("button", { name: "接触连接", exact: true }),
|
|
).toHaveCount(0);
|
|
await expect(contactEdge).not.toHaveClass(/selectable/);
|
|
await expect(contactEdge).not.toHaveAttribute("tabindex", "0");
|
|
await expect(contactEdge.locator(".react-flow__edge-interaction")).toHaveCount(0);
|
|
await expect(contactEdge.locator(".edge-endpoint-blocker")).toHaveCount(0);
|
|
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");
|
|
|
|
const firstPortCenter = await locatorCenter(firstPort);
|
|
const endpointTarget = await page.evaluate(
|
|
({ x, y }) => {
|
|
const target = document.elementFromPoint(x, y);
|
|
return {
|
|
className: target?.getAttribute("class") ?? "",
|
|
nodeId: target?.closest(".react-flow__node")?.getAttribute("data-id") ?? null,
|
|
};
|
|
},
|
|
firstPortCenter,
|
|
);
|
|
expect(endpointTarget.className).not.toContain("edge-endpoint-blocker");
|
|
expect(endpointTarget.className).not.toContain("port-handle");
|
|
expect(endpointTarget.className).not.toContain("react-flow__edge-interaction");
|
|
expect(endpointTarget.nodeId).not.toBeNull();
|
|
|
|
const unusedPort = firstOrifice.locator(
|
|
'.port-handle[data-port-name="port_1"]',
|
|
);
|
|
await unusedPort.click();
|
|
await expect(unusedPort).toHaveAttribute("data-connection-state", "origin");
|
|
await page.mouse.click(firstPortCenter.x, firstPortCenter.y);
|
|
await expectNoActivePortConnection(page);
|
|
await expect(contactEdge).not.toHaveClass(/selected|editor-edge-selected/);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
|
await expect(firstPort).toHaveAttribute("data-connected", "true");
|
|
await expect(secondPort).toHaveAttribute("data-connected", "true");
|
|
|
|
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
|
|
await expandSimulationConsole(page);
|
|
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 expect(xmlBlock).not.toContainText("kind=");
|
|
await expect(xmlBlock).not.toContainText("domain=");
|
|
await expect(xmlBlock).not.toContainText("role=");
|
|
|
|
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 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("PNCH012 四端口接触连接后主体各方向仍可直接选中", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
|
|
|
const chamber = await dragSymbolToCanvas(page, "amesim_pnch012", {
|
|
x: 500,
|
|
y: 350,
|
|
});
|
|
const chamberPorts = {
|
|
left: chamber.locator('.port-handle[data-port-name="port_1"]'),
|
|
right: chamber.locator('.port-handle[data-port-name="port_2"]'),
|
|
top: chamber.locator('.port-handle[data-port-name="port_3"]'),
|
|
bottom: chamber.locator('.port-handle[data-port-name="port_4"]'),
|
|
};
|
|
const rotateButton = page.getByRole("button", {
|
|
name: "顺时针旋转 90° (Ctrl+R)",
|
|
exact: true,
|
|
});
|
|
|
|
const snapBoundary = async (
|
|
targetPosition: { x: number; y: number },
|
|
targetPort: Locator,
|
|
rotationSteps: number,
|
|
) => {
|
|
const boundary = await dragSymbolToCanvas(
|
|
page,
|
|
"amesim_pnpl01",
|
|
targetPosition,
|
|
);
|
|
for (let step = 0; step < rotationSteps; step += 1) {
|
|
await rotateButton.click();
|
|
}
|
|
const boundaryPort = boundary.locator(
|
|
'.port-handle[data-port-name="port_1"]',
|
|
);
|
|
await dragNodePortNearTarget(page, boundary, boundaryPort, targetPort);
|
|
await page.mouse.up();
|
|
await expect(boundaryPort).toHaveAttribute("data-connected", "true");
|
|
await expect(targetPort).toHaveAttribute("data-connected", "true");
|
|
};
|
|
|
|
await snapBoundary({ x: 720, y: 350 }, chamberPorts.right, 0);
|
|
await snapBoundary({ x: 500, y: 160 }, chamberPorts.top, 3);
|
|
await snapBoundary({ x: 280, y: 350 }, chamberPorts.left, 2);
|
|
await snapBoundary({ x: 500, y: 540 }, chamberPorts.bottom, 1);
|
|
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(4);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge-interaction")).toHaveCount(0);
|
|
await expect(page.locator(".flow-canvas .edge-endpoint-blocker")).toHaveCount(0);
|
|
for (const port of Object.values(chamberPorts)) {
|
|
await expect(port).toHaveCSS("opacity", "0");
|
|
await expect(port).toHaveCSS("pointer-events", "none");
|
|
}
|
|
|
|
const envelope = chamber.locator('[data-symbol-envelope="true"]');
|
|
const envelopeBounds = await envelope.boundingBox();
|
|
expect(envelopeBounds).not.toBeNull();
|
|
const pane = page.locator(".flow-canvas .react-flow__pane");
|
|
for (const [xRatio, yRatio] of [
|
|
[0.5, 0.5],
|
|
[0.25, 0.5],
|
|
[0.75, 0.5],
|
|
[0.5, 0.25],
|
|
[0.5, 0.75],
|
|
] as const) {
|
|
await pane.click({ position: { x: 12, y: 12 } });
|
|
await page.mouse.click(
|
|
envelopeBounds!.x + envelopeBounds!.width * xRatio,
|
|
envelopeBounds!.y + envelopeBounds!.height * yRatio,
|
|
);
|
|
await expect(chamber).toHaveClass(/selected/);
|
|
await expect(
|
|
page.locator(".flow-canvas .react-flow__node.selected"),
|
|
).toHaveCount(1);
|
|
}
|
|
});
|
|
|
|
test("信号输出连接后隐藏且拒绝第二条连接", async ({ page }) => {
|
|
await page.goto("/");
|
|
const step = await dragSymbolToCanvas(page, "amesim_step0", {
|
|
x: 260,
|
|
y: 360,
|
|
});
|
|
const firstForce = await dragSymbolToCanvas(page, "amesim_forc", {
|
|
x: 620,
|
|
y: 260,
|
|
});
|
|
const secondForce = await dragSymbolToCanvas(page, "amesim_forc", {
|
|
x: 620,
|
|
y: 480,
|
|
});
|
|
const output = step.locator('.port-handle[data-port-name="out"]');
|
|
const firstInput = firstForce.locator('.port-handle[data-port-name="res"]');
|
|
const secondInput = secondForce.locator('.port-handle[data-port-name="res"]');
|
|
|
|
await output.click();
|
|
await firstInput.click();
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
|
await expect(output).toHaveAttribute("data-connected", "true");
|
|
await expect(output).toHaveCSS("opacity", "0");
|
|
await expect(output).toHaveCSS("pointer-events", "none");
|
|
await expect(firstInput).toHaveCSS("opacity", "0");
|
|
await expect(firstInput).toHaveCSS("pointer-events", "none");
|
|
|
|
await clickLocatorCenter(page, output);
|
|
await expectNoActivePortConnection(page);
|
|
await secondInput.click();
|
|
await expect(secondInput).toHaveAttribute("data-connection-state", "origin");
|
|
await clickLocatorCenter(page, output);
|
|
await expectNoActivePortConnection(page);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
|
await expect(output).toHaveCSS("opacity", "0");
|
|
await expect(secondInput).toHaveAttribute("data-connected", "false");
|
|
await expect(secondInput).toHaveCSS("opacity", "1");
|
|
await expect(secondInput).toHaveCSS("pointer-events", "auto");
|
|
|
|
await dragNodePortNearTarget(page, secondForce, secondInput, output);
|
|
await page.mouse.up();
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
|
await expect(output).toHaveAttribute("data-connected", "true");
|
|
await expect(secondInput).toHaveAttribute("data-connected", "false");
|
|
|
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
|
const addedLegacyFanOut = await page.evaluate(() => {
|
|
const key = "system-simulation-flow:project:demo-system";
|
|
const raw = window.localStorage.getItem(key);
|
|
if (!raw) {
|
|
return false;
|
|
}
|
|
const project = JSON.parse(raw) as {
|
|
edges?: Array<Record<string, unknown>>;
|
|
};
|
|
const firstEdge = project.edges?.[0];
|
|
if (!firstEdge || !project.edges) {
|
|
return false;
|
|
}
|
|
project.edges.push({
|
|
...firstEdge,
|
|
id: "legacy-fan-out-edge",
|
|
target: "amesim_forc_2",
|
|
targetHandle: "res",
|
|
});
|
|
window.localStorage.setItem(key, JSON.stringify(project));
|
|
return true;
|
|
});
|
|
expect(addedLegacyFanOut).toBe(true);
|
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
|
await expect(output).toHaveCSS("opacity", "0");
|
|
await expect(firstInput).toHaveCSS("opacity", "0");
|
|
await expect(secondInput).toHaveCSS("opacity", "1");
|
|
await page.getByRole("button", { name: "展开仿真控制台" }).click();
|
|
await expect(
|
|
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
|
).toContainText(
|
|
"已丢弃失效连接 legacy-fan-out-edge:当前目录合同不允许端口被多条连接占用",
|
|
);
|
|
|
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
|
const normalizedEdgeIds = await page.evaluate(() => {
|
|
const raw = window.localStorage.getItem(
|
|
"system-simulation-flow:project:demo-system",
|
|
);
|
|
const project = raw ? JSON.parse(raw) : null;
|
|
return (project?.edges ?? []).map((edge: { id?: string }) => edge.id);
|
|
});
|
|
expect(normalizedEdgeIds).toHaveLength(1);
|
|
expect(normalizedEdgeIds).not.toContain("legacy-fan-out-edge");
|
|
|
|
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
|
|
await expandSimulationConsole(page);
|
|
const xml = await page.getByLabel("生成的系统 XML").textContent();
|
|
expect(xml?.match(/<Connection /g)).toHaveLength(1);
|
|
expect(xml?.match(/component="amesim_step0_1" port="out"/g)).toHaveLength(1);
|
|
});
|
|
|
|
test("一次拖动会吸附同一落点内的全部兼容接口", async ({ page }) => {
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
|
|
|
const targetChamber = await dragSymbolToCanvas(page, "amesim_pnch023", {
|
|
x: 300,
|
|
y: 320,
|
|
});
|
|
const movingChamber = await dragSymbolToCanvas(page, "amesim_pnch023", {
|
|
x: 620,
|
|
y: 520,
|
|
});
|
|
const leftTargetPort = targetChamber.locator(
|
|
'.port-handle[data-port-name="port_1"]',
|
|
);
|
|
const rightTargetPort = targetChamber.locator(
|
|
'.port-handle[data-port-name="port_2"]',
|
|
);
|
|
const movingLeftPort = movingChamber.locator(
|
|
'.port-handle[data-port-name="port_1"]',
|
|
);
|
|
const movingRightPort = movingChamber.locator(
|
|
'.port-handle[data-port-name="port_2"]',
|
|
);
|
|
|
|
await dragNodePortNearTarget(
|
|
page,
|
|
movingChamber,
|
|
movingLeftPort,
|
|
leftTargetPort,
|
|
);
|
|
await expect(movingLeftPort).toHaveAttribute("data-connection-state", "origin");
|
|
await expect(movingRightPort).toHaveAttribute("data-connection-state", "origin");
|
|
await expect(leftTargetPort).toHaveAttribute(
|
|
"data-connection-state",
|
|
"compatible",
|
|
);
|
|
await expect(rightTargetPort).toHaveAttribute(
|
|
"data-connection-state",
|
|
"compatible",
|
|
);
|
|
await page.mouse.up();
|
|
|
|
await expect.poll(() => distanceBetween(movingLeftPort, leftTargetPort)).toBeLessThan(2);
|
|
await expect.poll(() => distanceBetween(movingRightPort, rightTargetPort)).toBeLessThan(2);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
|
|
await expect(page.locator(".flow-canvas .contact-edge-hit")).toHaveCount(0);
|
|
await expect(page.locator(".flow-canvas .contact-edge-marker")).toHaveCount(0);
|
|
await expect(
|
|
page.getByRole("button", { name: "接触连接", exact: true }),
|
|
).toHaveCount(0);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
|
|
await expect(
|
|
page.locator(".flow-canvas .react-flow__edge-interaction"),
|
|
).toHaveCount(0);
|
|
const contactEdges = page.locator(".flow-canvas .react-flow__edge");
|
|
for (let index = 0; index < (await contactEdges.count()); index += 1) {
|
|
await expect(contactEdges.nth(index)).not.toHaveClass(/selectable/);
|
|
await expect(contactEdges.nth(index)).not.toHaveAttribute("tabindex", "0");
|
|
await expect(
|
|
contactEdges.nth(index).locator(".edge-endpoint-blocker"),
|
|
).toHaveCount(0);
|
|
}
|
|
for (const port of [
|
|
movingLeftPort,
|
|
movingRightPort,
|
|
leftTargetPort,
|
|
rightTargetPort,
|
|
]) {
|
|
await expect(port).toHaveAttribute("data-connected", "true");
|
|
}
|
|
|
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
|
const savedEndpointKeys = await page.evaluate(() => {
|
|
const raw = window.localStorage.getItem(
|
|
"system-simulation-flow:project:demo-system",
|
|
);
|
|
const edges = raw ? (JSON.parse(raw)?.edges ?? []) : [];
|
|
return edges.flatMap(
|
|
(edge: {
|
|
source: string;
|
|
sourceHandle?: string;
|
|
target: string;
|
|
targetHandle?: string;
|
|
}) => [
|
|
`${edge.source}:${edge.sourceHandle ?? ""}`,
|
|
`${edge.target}:${edge.targetHandle ?? ""}`,
|
|
],
|
|
);
|
|
});
|
|
expect(savedEndpointKeys).toHaveLength(4);
|
|
expect(new Set(savedEndpointKeys)).toHaveProperty("size", 4);
|
|
});
|
|
|
|
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(2);
|
|
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({
|
|
projectSchemaVersion: 1,
|
|
name: "catalog-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);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const raw = window.localStorage.getItem(
|
|
"system-simulation-flow:autosave",
|
|
);
|
|
const project = raw ? JSON.parse(raw) : null;
|
|
return project
|
|
? {
|
|
position: project.nodes?.[0]?.position,
|
|
version: project.projectSchemaVersion,
|
|
hasNodeSize:
|
|
"width" in (project.nodes?.[0] ?? {}) ||
|
|
"height" in (project.nodes?.[0] ?? {}),
|
|
}
|
|
: null;
|
|
}),
|
|
)
|
|
.toEqual({
|
|
position: { x: 200, y: 160 },
|
|
version: 1,
|
|
hasNodeSize: false,
|
|
});
|
|
|
|
await page.reload();
|
|
await page.getByRole("button", { name: "恢复", exact: true }).click();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const raw = window.localStorage.getItem(
|
|
"system-simulation-flow:autosave",
|
|
);
|
|
const project = raw ? JSON.parse(raw) : null;
|
|
return project?.nodes?.[0]?.position ?? null;
|
|
}),
|
|
)
|
|
.toEqual({ x: 200, y: 160 });
|
|
});
|
|
|
|
|
|
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(Math.abs(nodeBounds!.width - 112)).toBeLessThan(1);
|
|
expect(Math.abs(nodeBounds!.height - 112)).toBeLessThan(1);
|
|
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 outsideTightNode = {
|
|
x: nodeBounds!.x - 8,
|
|
y: envelopeBounds!.y + envelopeBounds!.height / 2,
|
|
};
|
|
await page.mouse.click(outsideTightNode.x, outsideTightNode.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");
|
|
});
|
|
|
|
test("旋转后的低占比专用图标框选只按实际图形包络命中", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
const pane = page.locator(".flow-canvas .react-flow__pane");
|
|
const node = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 430,
|
|
y: 300,
|
|
});
|
|
const simNode = node.locator(".sim-node");
|
|
const envelope = node.locator('[data-symbol-envelope="true"]');
|
|
const selection = page.locator(".flow-canvas .react-flow__selection");
|
|
|
|
// PNPL01 的主体只占 compact 画布的一小部分;旋转后仍应使用实际图形
|
|
// 包络框选,不能因为选择框碰到透明的 React Flow 外层节点而误选。
|
|
await page
|
|
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
|
|
.click();
|
|
await expect(simNode).toHaveAttribute("data-node-layout-width", "80");
|
|
await expect(simNode).toHaveAttribute("data-node-layout-height", "100");
|
|
|
|
const paneBounds = await pane.boundingBox();
|
|
const nodeBounds = await node.boundingBox();
|
|
const envelopeBounds = await envelope.boundingBox();
|
|
expect(paneBounds).not.toBeNull();
|
|
expect(nodeBounds).not.toBeNull();
|
|
expect(envelopeBounds).not.toBeNull();
|
|
|
|
await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8);
|
|
await expect(node).not.toHaveClass(/selected/);
|
|
|
|
const blankStart = {
|
|
x: nodeBounds!.x + nodeBounds!.width - 18,
|
|
y: nodeBounds!.y + nodeBounds!.height - 18,
|
|
};
|
|
const blankEnd = {
|
|
x: nodeBounds!.x + nodeBounds!.width - 6,
|
|
y: nodeBounds!.y + nodeBounds!.height - 6,
|
|
};
|
|
expect(blankStart.x).toBeGreaterThan(
|
|
envelopeBounds!.x + envelopeBounds!.width + 2,
|
|
);
|
|
expect(blankStart.y).toBeGreaterThan(
|
|
envelopeBounds!.y + envelopeBounds!.height + 2,
|
|
);
|
|
|
|
await page.mouse.move(blankStart.x, blankStart.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(blankEnd.x, blankEnd.y, { steps: 6 });
|
|
await expect(selection).toHaveCount(1);
|
|
await page.mouse.up();
|
|
await expect(selection).toHaveCount(0);
|
|
await expect(node).not.toHaveClass(/selected/);
|
|
|
|
const envelopeStart = {
|
|
x: envelopeBounds!.x - 8,
|
|
y: envelopeBounds!.y - 8,
|
|
};
|
|
const envelopeEnd = {
|
|
x: envelopeBounds!.x + envelopeBounds!.width / 2,
|
|
y: envelopeBounds!.y + envelopeBounds!.height / 2,
|
|
};
|
|
await page.mouse.move(envelopeStart.x, envelopeStart.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(envelopeEnd.x, envelopeEnd.y, { steps: 8 });
|
|
await expect(selection).toHaveCount(1);
|
|
await page.mouse.up();
|
|
await expect(selection).toHaveCount(0);
|
|
await expect(node).toHaveClass(/selected/);
|
|
await expect(envelope).toHaveCSS("outline-style", "dashed");
|
|
|
|
const clickTarget = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 650,
|
|
y: 300,
|
|
});
|
|
const clickTargetEnvelope = clickTarget.locator(
|
|
'[data-symbol-envelope="true"]',
|
|
);
|
|
const clickTargetEnvelopeBounds = await clickTargetEnvelope.boundingBox();
|
|
expect(clickTargetEnvelopeBounds).not.toBeNull();
|
|
await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8);
|
|
await expect(node).not.toHaveClass(/selected/);
|
|
await expect(clickTarget).not.toHaveClass(/selected/);
|
|
|
|
await page.mouse.move(envelopeStart.x, envelopeStart.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(envelopeEnd.x, envelopeEnd.y, { steps: 8 });
|
|
await expect(selection).toHaveCount(1);
|
|
await pane.dispatchEvent("pointercancel", {
|
|
bubbles: true,
|
|
button: 0,
|
|
buttons: 0,
|
|
clientX: envelopeEnd.x,
|
|
clientY: envelopeEnd.y,
|
|
isPrimary: true,
|
|
pointerId: 1,
|
|
pointerType: "mouse",
|
|
});
|
|
await expect(selection).toHaveCount(0);
|
|
await expect(node).toHaveClass(/selected/);
|
|
await expect(clickTarget).not.toHaveClass(/selected/);
|
|
await expect(envelope).toHaveCSS("outline-style", "dashed");
|
|
|
|
// Playwright 不知道页面内已派发 pointercancel;在画布外释放其鼠标状态,
|
|
// 避免额外 mouseup 经过 React Flow 并替我们清理内部框选标记。
|
|
await page.mouse.move(4, 4);
|
|
await page.mouse.up();
|
|
await clickTargetEnvelope.click();
|
|
await expect(selection).toHaveCount(0);
|
|
await expect(node).not.toHaveClass(/selected/);
|
|
await expect(clickTarget).toHaveClass(/selected/);
|
|
});
|
|
|
|
test("活动框选缩回到实际包络外时立即清除上一帧误选", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
const pane = page.locator(".flow-canvas .react-flow__pane");
|
|
const node = await dragSymbolToCanvas(page, "amesim_pnch012", {
|
|
x: 540,
|
|
y: 360,
|
|
});
|
|
const envelope = node.locator('[data-symbol-envelope="true"]');
|
|
const selection = page.locator(".flow-canvas .react-flow__selection");
|
|
const [paneBounds, envelopeBounds] = await Promise.all([
|
|
pane.boundingBox(),
|
|
envelope.boundingBox(),
|
|
]);
|
|
expect(paneBounds).not.toBeNull();
|
|
expect(envelopeBounds).not.toBeNull();
|
|
|
|
await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8);
|
|
await expect(node).not.toHaveClass(/selected/);
|
|
|
|
const selectionStart = {
|
|
x: envelopeBounds!.x - 60,
|
|
y: envelopeBounds!.y - 18,
|
|
};
|
|
const coveredEnd = {
|
|
x: envelopeBounds!.x + envelopeBounds!.width / 2,
|
|
y: envelopeBounds!.y + envelopeBounds!.height / 2,
|
|
};
|
|
const separatedEnd = {
|
|
x: envelopeBounds!.x - 8,
|
|
y: envelopeBounds!.y + envelopeBounds!.height / 2,
|
|
};
|
|
|
|
await page.mouse.move(selectionStart.x, selectionStart.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(coveredEnd.x, coveredEnd.y, { steps: 8 });
|
|
await expect(selection).toHaveCount(1);
|
|
await expect(node).toHaveClass(/selected/);
|
|
|
|
await page.mouse.move(separatedEnd.x, separatedEnd.y, { steps: 8 });
|
|
const activeSelectionBounds = await selection.boundingBox();
|
|
expect(activeSelectionBounds).not.toBeNull();
|
|
expect(activeSelectionBounds!.width).toBeGreaterThan(0);
|
|
expect(activeSelectionBounds!.height).toBeGreaterThan(0);
|
|
expect(activeSelectionBounds!.x + activeSelectionBounds!.width).toBeLessThan(
|
|
envelopeBounds!.x - 4,
|
|
);
|
|
await expect(node).not.toHaveClass(/selected/);
|
|
await expect(node.locator(".port-index-label")).toHaveCount(0);
|
|
|
|
await page.mouse.up();
|
|
await expect(selection).toHaveCount(0);
|
|
await expect(node).not.toHaveClass(/selected/);
|
|
});
|
|
|
|
test("零高度及纯水平框选不会复用上一帧的节点和边选择", async ({ page }) => {
|
|
await page.goto("/");
|
|
|
|
const pane = page.locator(".flow-canvas .react-flow__pane");
|
|
const firstNode = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 350,
|
|
y: 300,
|
|
});
|
|
const secondNode = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 650,
|
|
y: 300,
|
|
});
|
|
const firstHandle = firstNode.locator(
|
|
'.port-handle[data-port-name="port_1"]',
|
|
);
|
|
const secondHandle = secondNode.locator(
|
|
'.port-handle[data-port-name="port_1"]',
|
|
);
|
|
await firstHandle.click();
|
|
await secondHandle.click();
|
|
|
|
const edge = page.locator(".flow-canvas .react-flow__edge");
|
|
const selection = page.locator(".flow-canvas .react-flow__selection");
|
|
const firstEnvelopeBounds = await firstNode
|
|
.locator('[data-symbol-envelope="true"]')
|
|
.boundingBox();
|
|
const paneBounds = await pane.boundingBox();
|
|
expect(firstEnvelopeBounds).not.toBeNull();
|
|
expect(paneBounds).not.toBeNull();
|
|
await expect(edge).toHaveCount(1);
|
|
|
|
await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8);
|
|
await expect(firstNode).not.toHaveClass(/selected/);
|
|
await expect(secondNode).not.toHaveClass(/selected/);
|
|
await expect(edge).not.toHaveClass(/selected|editor-edge-selected/);
|
|
|
|
const selectionStart = {
|
|
x: firstEnvelopeBounds!.x - 8,
|
|
y: firstEnvelopeBounds!.y - 8,
|
|
};
|
|
const positiveAreaEnd = {
|
|
x: firstEnvelopeBounds!.x + firstEnvelopeBounds!.width + 8,
|
|
y: firstEnvelopeBounds!.y + firstEnvelopeBounds!.height + 8,
|
|
};
|
|
const zeroHeightEnd = {
|
|
x: positiveAreaEnd.x,
|
|
y: selectionStart.y,
|
|
};
|
|
|
|
await page.mouse.move(selectionStart.x, selectionStart.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(positiveAreaEnd.x, positiveAreaEnd.y, { steps: 8 });
|
|
await expect(selection).toHaveCount(1);
|
|
await expect(firstNode).toHaveClass(/selected/);
|
|
await expect(edge).not.toHaveClass(/selected|editor-edge-selected/);
|
|
|
|
await page.mouse.move(zeroHeightEnd.x, zeroHeightEnd.y, { steps: 6 });
|
|
await expect(selection).toHaveCount(1);
|
|
await page.mouse.up();
|
|
await expect(selection).toHaveCount(0);
|
|
await expect(firstNode).not.toHaveClass(/selected/);
|
|
await expect(secondNode).not.toHaveClass(/selected/);
|
|
await expect(edge).not.toHaveClass(/selected|editor-edge-selected/);
|
|
|
|
// 新一轮手势从一开始就是纯水平框选,不得复用上一轮的正面积矩形。
|
|
await page.mouse.move(selectionStart.x, selectionStart.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(zeroHeightEnd.x, zeroHeightEnd.y, { steps: 8 });
|
|
await expect(selection).toHaveCount(1);
|
|
await page.mouse.up();
|
|
await expect(selection).toHaveCount(0);
|
|
await expect(firstNode).not.toHaveClass(/selected/);
|
|
await expect(secondNode).not.toHaveClass(/selected/);
|
|
await expect(edge).not.toHaveClass(/selected|editor-edge-selected/);
|
|
});
|
|
|
|
test("window blur 会结束活动框选且不吞掉恢复后的首次节点点击", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const pane = page.locator(".flow-canvas .react-flow__pane");
|
|
const canvas = page.locator(".flow-canvas");
|
|
const firstNode = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 350,
|
|
y: 300,
|
|
});
|
|
const secondNode = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 650,
|
|
y: 300,
|
|
});
|
|
const firstEnvelope = firstNode.locator('[data-symbol-envelope="true"]');
|
|
const secondEnvelope = secondNode.locator('[data-symbol-envelope="true"]');
|
|
const selection = page.locator(".flow-canvas .react-flow__selection");
|
|
const paneBounds = await pane.boundingBox();
|
|
const canvasBounds = await canvas.boundingBox();
|
|
const firstEnvelopeBounds = await firstEnvelope.boundingBox();
|
|
expect(paneBounds).not.toBeNull();
|
|
expect(canvasBounds).not.toBeNull();
|
|
expect(firstEnvelopeBounds).not.toBeNull();
|
|
|
|
await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8);
|
|
await expect(firstNode).not.toHaveClass(/selected/);
|
|
await expect(secondNode).not.toHaveClass(/selected/);
|
|
|
|
const selectionStart = {
|
|
x: firstEnvelopeBounds!.x - 8,
|
|
y: firstEnvelopeBounds!.y - 8,
|
|
};
|
|
const selectionEnd = {
|
|
x: firstEnvelopeBounds!.x + firstEnvelopeBounds!.width / 2,
|
|
y: firstEnvelopeBounds!.y + firstEnvelopeBounds!.height / 2,
|
|
};
|
|
await page.mouse.move(selectionStart.x, selectionStart.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(selectionEnd.x, selectionEnd.y, { steps: 8 });
|
|
await expect(selection).toHaveCount(1);
|
|
|
|
await page.evaluate(() => window.dispatchEvent(new FocusEvent("blur")));
|
|
await expect(selection).toHaveCount(0);
|
|
await expect(firstNode).toHaveClass(/selected/);
|
|
await expect(secondNode).not.toHaveClass(/selected/);
|
|
|
|
// blur 已经完成框选收尾;这里只在画布外释放 Playwright 的鼠标设备状态。
|
|
const outsideCanvas = {
|
|
x: Math.max(1, canvasBounds!.x - 8),
|
|
y: Math.max(1, canvasBounds!.y - 8),
|
|
};
|
|
expect(outsideCanvas.x).toBeLessThan(canvasBounds!.x);
|
|
await page.mouse.move(outsideCanvas.x, outsideCanvas.y);
|
|
await page.mouse.up();
|
|
await expect(selection).toHaveCount(0);
|
|
await expect(firstNode).toHaveClass(/selected/);
|
|
|
|
await secondEnvelope.click();
|
|
await expect(firstNode).not.toHaveClass(/selected/);
|
|
await expect(secondNode).toHaveClass(/selected/);
|
|
await expect(firstNode.locator(".port-index-label")).toHaveCount(0);
|
|
await expect(secondNode.locator(".port-index-label")).toHaveCount(1);
|
|
});
|
|
|
|
test("多节点框选不会把仅接触透明外框的专用图标加入选择集", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
|
|
const pane = page.locator(".flow-canvas .react-flow__pane");
|
|
const firstNode = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 350,
|
|
y: 300,
|
|
});
|
|
const secondNode = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 520,
|
|
y: 300,
|
|
});
|
|
const thirdNode = await dragSymbolToCanvas(page, "amesim_pnpl01", {
|
|
x: 700,
|
|
y: 300,
|
|
});
|
|
const firstEnvelope = firstNode.locator('[data-symbol-envelope="true"]');
|
|
const secondEnvelope = secondNode.locator('[data-symbol-envelope="true"]');
|
|
const selection = page.locator(".flow-canvas .react-flow__selection");
|
|
const secondHandle = secondNode.locator(
|
|
'.port-handle[data-port-name="port_1"]',
|
|
);
|
|
const thirdHandle = thirdNode.locator(
|
|
'.port-handle[data-port-name="port_1"]',
|
|
);
|
|
await secondHandle.click();
|
|
await thirdHandle.click();
|
|
const unrelatedEdge = page.locator(".flow-canvas .react-flow__edge");
|
|
await expect(unrelatedEdge).toHaveCount(1);
|
|
|
|
const paneBounds = await pane.boundingBox();
|
|
const firstEnvelopeBounds = await firstEnvelope.boundingBox();
|
|
const secondNodeBounds = await secondNode.boundingBox();
|
|
const secondEnvelopeBounds = await secondEnvelope.boundingBox();
|
|
expect(paneBounds).not.toBeNull();
|
|
expect(firstEnvelopeBounds).not.toBeNull();
|
|
expect(secondNodeBounds).not.toBeNull();
|
|
expect(secondEnvelopeBounds).not.toBeNull();
|
|
|
|
await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8);
|
|
await expect(firstNode).not.toHaveClass(/selected/);
|
|
await expect(secondNode).not.toHaveClass(/selected/);
|
|
await expect(thirdNode).not.toHaveClass(/selected/);
|
|
|
|
const selectionStart = {
|
|
x: firstEnvelopeBounds!.x - 8,
|
|
y: firstEnvelopeBounds!.y - 8,
|
|
};
|
|
const selectionEnd = {
|
|
x: secondEnvelopeBounds!.x - 6,
|
|
y: firstEnvelopeBounds!.y + firstEnvelopeBounds!.height + 8,
|
|
};
|
|
expect(selectionEnd.x).toBeGreaterThan(secondNodeBounds!.x + 2);
|
|
expect(selectionEnd.x).toBeLessThan(secondEnvelopeBounds!.x - 2);
|
|
|
|
await page.mouse.move(selectionStart.x, selectionStart.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(selectionEnd.x, selectionEnd.y, { steps: 12 });
|
|
await expect(selection).toHaveCount(1);
|
|
await page.mouse.up();
|
|
await expect(selection).toHaveCount(0);
|
|
|
|
await expect(firstNode).toHaveClass(/selected/);
|
|
await expect(secondNode).not.toHaveClass(/selected/);
|
|
await expect(thirdNode).not.toHaveClass(/selected/);
|
|
await expect(unrelatedEdge).not.toHaveClass(
|
|
/selected|editor-edge-selected/,
|
|
);
|
|
await expect(firstNode.locator(".port-index-label")).toHaveCount(1);
|
|
await expect(secondNode.locator(".port-index-label")).toHaveCount(0);
|
|
await expect(thirdNode.locator(".port-index-label")).toHaveCount(0);
|
|
|
|
await page
|
|
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
|
|
.click();
|
|
await expect(firstNode.locator(".sim-node")).toHaveAttribute(
|
|
"data-node-layout-width",
|
|
"80",
|
|
);
|
|
await expect(firstNode.locator(".sim-node")).toHaveAttribute(
|
|
"data-node-layout-height",
|
|
"100",
|
|
);
|
|
await expect(secondNode.locator(".sim-node")).toHaveAttribute(
|
|
"data-node-layout-width",
|
|
"100",
|
|
);
|
|
await expect(secondNode.locator(".sim-node")).toHaveAttribute(
|
|
"data-node-layout-height",
|
|
"80",
|
|
);
|
|
await expect(thirdNode.locator(".sim-node")).toHaveAttribute(
|
|
"data-node-layout-width",
|
|
"100",
|
|
);
|
|
await expect(thirdNode.locator(".sim-node")).toHaveAttribute(
|
|
"data-node-layout-height",
|
|
"80",
|
|
);
|
|
await expect(secondNode.locator(".port-index-label")).toHaveCount(0);
|
|
await expect(thirdNode.locator(".port-index-label")).toHaveCount(0);
|
|
await expect(unrelatedEdge).not.toHaveClass(
|
|
/selected|editor-edge-selected/,
|
|
);
|
|
});
|