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

4612 lines
163 KiB
TypeScript

import { expect, test, type Locator, type Page } from "@playwright/test";
import { readFileSync } from "node:fs";
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", "right", 10),
physicalPort("port_3", "left", 20),
];
}
if (modelType === "amesim_pnvo001") {
return [
signalPort("res", "left", "input", 5),
physicalPort("port_2", "right", 10),
physicalPort("port_3", "left", 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_3", x: 12.8, y: 24 },
{ portName: "port_2", 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_3", x: 12.8, y: 24 },
{ portName: "port_2", 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("/");
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,
);
}
const placedTarget = { x: 601, y: 613 };
const placedNode = await dragSymbolToCanvas(
page,
"amesim_f000",
placedTarget,
);
const placedBounds = await placedNode.boundingBox();
expect(placedBounds).not.toBeNull();
expect(
Math.abs(
placedBounds!.x + placedBounds!.width / 2 -
(paneBounds!.x + placedTarget.x),
),
).toBeLessThan(2);
expect(
Math.abs(
placedBounds!.y + placedBounds!.height / 2 -
(paneBounds!.y + placedTarget.y),
),
).toBeLessThan(2);
});
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 === "system-simulation-flow:autosave",
);
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 === "system-simulation-flow:autosave",
);
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("/");
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 === "system-simulation-flow:autosave",
);
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 === "system-simulation-flow:autosave",
);
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: 32, y: 36.8 },
{ portName: "port_4", x: 44.8, y: 24 },
],
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: "bottom",
port_4: "right",
},
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("P4NODE2 的真实 Handle 在 180° 时匹配 AME 四口方位,镜像后参考粗线仍指向 port_2", async ({ page }) => {
const source = JSON.parse(readFileSync(new URL(
"../../../tests/model_audit/p4node2-ame-geometry.json", import.meta.url,
), "utf8")) as {
components: Array<{ geometry: number; ports: Record<string, { x: number; y: number }> }>;
};
// Independent AME COMP_PORT/PORT_POS coordinates, rather than frontend anchors.
const amePorts = source.components[0].ports;
for (const component of source.components) {
expect(component.geometry).toBe(1);
expect(component.ports).toEqual(amePorts);
}
const ameCenter = Object.values(amePorts).reduce(
(sum, port) => ({ x: sum.x + port.x / 4, y: sum.y + port.y / 4 }),
{ x: 0, y: 0 },
);
await page.goto("/");
const node = await dragSymbolToCanvas(page, "amesim_p4node2", { x: 460, y: 320 });
const rotate = page.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true });
await rotate.click();
await rotate.click();
const svg = node.locator('[data-component-symbol="amesim_p4node2"] svg');
const getDirection = async (portName: string) => {
const center = await locatorCenter(svg.locator('[data-junction-part="center"]'));
const handle = await locatorCenter(node.locator(`.port-handle[data-port-name="${portName}"]`));
const length = Math.hypot(handle.x - center.x, handle.y - center.y);
expect(length).toBeGreaterThan(1);
return { x: (handle.x - center.x) / length, y: (handle.y - center.y) / length };
};
for (const [portName, coordinate] of Object.entries(amePorts)) {
const length = Math.hypot(coordinate.x - ameCenter.x, coordinate.y - ameCenter.y);
await expect.poll(async () => {
const actual = await getDirection(portName);
return Math.hypot(actual.x - (coordinate.x - ameCenter.x) / length,
actual.y - (coordinate.y - ameCenter.y) / length);
}).toBeLessThan(0.02);
}
const expectReferenceStroke = async () => {
const center = await locatorCenter(svg.locator('[data-junction-part="center"]'));
const stroke = await locatorCenter(svg.locator('[data-junction-part="collector-branch"]'));
const reference = await getDirection("port_2");
const length = Math.hypot(stroke.x - center.x, stroke.y - center.y);
expect((stroke.x - center.x) * reference.x + (stroke.y - center.y) * reference.y).toBeGreaterThan(0);
expect(Math.abs((stroke.x - center.x) * reference.y - (stroke.y - center.y) * reference.x) / length).toBeLessThan(0.02);
};
await expectReferenceStroke();
await page.getByRole("button", { name: "水平镜像 (Shift+H)", exact: true }).click();
await expect.poll(async () => (await getDirection("port_2")).x).toBeLessThan(-0.99);
await expectReferenceStroke();
});
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 = 3;
} 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 === "system-simulation-flow:autosave",
);
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: 51.2, y: 24 },
{ portName: "port_3", x: 12.8, 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",
"origin",
);
const manualDraft = page.locator(".flow-canvas .manual-connection-draft");
await expect(manualDraft).toBeVisible();
await expect(manualDraft.locator(".react-flow__connection-path")).toHaveAttribute(
"data-waypoint-count",
"1",
);
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(firstPhysicalOutput).toHaveAttribute(
"data-connection-state",
"idle",
);
await expect(manualDraft).toHaveCount(0);
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 page.locator(".flow-canvas .react-flow__pane").click({
position: { x: 70, y: 90 },
});
await expect(manualDraft.locator(".react-flow__connection-path")).toHaveAttribute(
"data-waypoint-count",
"1",
);
await compatiblePhysicalPort.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(manualDraft).toHaveCount(0);
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 page.getByRole("button", { name: "保存工程", exact: true }).click();
const routedEdge = await page.evaluate(() => {
const raw = window.localStorage.getItem(
"system-simulation-flow:autosave",
);
const project = raw ? JSON.parse(raw) : null;
return project?.edges?.[0] ?? null;
});
expect(routedEdge?.data?.routePoints?.length).toBeGreaterThan(0);
await signalOutput.click();
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
await clickLocatorCenter(page, firstPhysicalOutput);
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(1);
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 expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(2);
await page.keyboard.press("Escape");
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 (currentZoom: number) => {
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 * currentZoom)).toBeLessThan(0.1);
expect(Math.abs(shape.handleHeight - 6 * currentZoom)).toBeLessThan(0.1);
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(initialZoom);
const initialPortStyles = await scalablePorts.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("style")),
);
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();
await expect
.poll(async () => {
const hitScale = await page
.locator(".flow-canvas")
.evaluate((element) =>
Number(
element.style.getPropertyValue("--port-handle-hit-scale"),
),
);
return Math.abs(hitScale - 1 / zoomedZoom);
})
.toBeLessThan(0.02);
const zoomedShapes = await readPortShapes(zoomedZoom);
const zoomedPortStyles = await scalablePorts.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("style")),
);
expect(zoomedPortStyles).toEqual(initialPortStyles);
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);
}
// 可见端口随画布缩小,但透明命中区仍保持易于点击的屏幕尺寸。
const firstZoomedPort = scalablePorts.first();
const firstZoomedPortBounds = await firstZoomedPort.boundingBox();
expect(firstZoomedPortBounds).not.toBeNull();
const hitPortName = await page.evaluate(
({ x, y }) =>
document
.elementFromPoint(x, y)
?.closest(".port-handle")
?.getAttribute("data-port-name") ?? null,
{
x: firstZoomedPortBounds!.x - 3,
y: firstZoomedPortBounds!.y + firstZoomedPortBounds!.height / 2,
},
);
expect(hitPortName).toBe(
await firstZoomedPort.getAttribute("data-port-name"),
);
});
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("/");
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 expect(unusedPort).toHaveAttribute("data-connection-state", "origin");
await expect(page.locator(".manual-connection-waypoint")).toHaveCount(1);
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.keyboard.press("Escape");
await expectNoActivePortConnection(page);
await secondOrifice.locator(".node-symbol").click();
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:autosave",
);
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("/");
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:autosave";
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:autosave",
);
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("/");
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:autosave",
);
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("/");
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("/");
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: 2,
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/,
);
});