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

3101 lines
106 KiB
TypeScript

import { expect, test, type Locator, type Page } from "@playwright/test";
type SymbolCase = {
category: string;
domain: "medium" | "pneumatic" | "signal" | "mechanical";
modelType: string;
};
const symbolCases: SymbolCase[] = [
{ category: "media", domain: "medium", modelType: "amesim_ideal_air_medium" },
{ category: "media", domain: "medium", modelType: "amesim_helium_medium" },
{ category: "storage", domain: "pneumatic", modelType: "amesim_pnch023" },
{ category: "storage", domain: "pneumatic", modelType: "amesim_pnch012" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnor001" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnvo001_fixed" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnvo001" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnl00r" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnl0001" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnl0002" },
{ category: "flow", domain: "pneumatic", modelType: "amesim_pnl0003" },
{ category: "junctions", domain: "pneumatic", modelType: "amesim_pn3node2" },
{ category: "junctions", domain: "pneumatic", modelType: "amesim_p4node2" },
{ category: "boundary", domain: "pneumatic", modelType: "amesim_pnpl01" },
{ category: "signals", domain: "signal", modelType: "amesim_step0" },
{ category: "signals", domain: "signal", modelType: "amesim_ud00" },
{ category: "mechanical", domain: "mechanical", modelType: "amesim_f000" },
{ category: "mechanical", domain: "mechanical", modelType: "amesim_forc" },
{ category: "mechanical", domain: "mechanical", modelType: "amesim_mecmas21" },
{ category: "mechanical", domain: "mechanical", modelType: "amesim_lstp00a" },
{ category: "mechanical", domain: "mechanical", modelType: "amesim_lmechn1" },
{ category: "mechanical", domain: "mechanical", modelType: "amesim_pnrp17" },
];
function physicalPort(
name: string,
side: "left" | "right",
order: number,
) {
return {
name,
kind: "physical",
domain: "pneumatic",
nominalRole: "bidirectional",
positiveFlowDirection: "intoComponent",
side,
order,
};
}
function signalPort(
name: string,
side: "left" | "right",
nominalRole: "input" | "output",
order: number,
) {
return {
name,
kind: "signal",
domain: "signal",
nominalRole,
side,
order,
};
}
function mechanicalPort(
name: string,
side: "left" | "right",
order: number,
) {
return {
name,
kind: "physical",
domain: "mechanical",
nominalRole: "bidirectional",
positiveFlowDirection: "intoComponent",
side,
order,
};
}
function portsForSymbol(modelType: string) {
if (modelType === "amesim_pnpl01") {
return [physicalPort("port_1", "left", 10)];
}
if (modelType === "amesim_pnch023") {
return [
physicalPort("port_1", "left", 10),
physicalPort("port_2", "right", 20),
];
}
if (modelType === "amesim_pnch012") {
return [
physicalPort("port_1", "left", 10),
physicalPort("port_2", "right", 20),
physicalPort("port_3", "left", 30),
physicalPort("port_4", "right", 40),
];
}
if (modelType === "amesim_pnor001") {
return [
physicalPort("port_1", "left", 10),
physicalPort("port_2", "right", 20),
];
}
if (modelType === "amesim_pnvo001_fixed") {
return [
physicalPort("port_2", "left", 10),
physicalPort("port_3", "right", 20),
];
}
if (modelType === "amesim_pnvo001") {
return [
signalPort("res", "left", "input", 5),
physicalPort("port_2", "left", 10),
physicalPort("port_3", "right", 20),
];
}
if (modelType === "amesim_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_lmechn1") {
return [
...Array.from({ length: 8 }, (_, index) =>
mechanicalPort(`port_${index + 1}`, "left", (index + 1) * 10),
),
mechanicalPort("port_9", "right", 90),
];
}
if (modelType === "amesim_pnrp17") {
return [
physicalPort("port_1", "left", 10),
mechanicalPort("port_3", "left", 20),
mechanicalPort("port_4", "left", 30),
mechanicalPort("port_2", "right", 40),
mechanicalPort("port_5", "right", 50),
];
}
return [];
}
async function dragSymbolToCanvas(
page: Page,
modelType: string,
targetPosition: { x: number; y: number },
) {
const pane = page.locator(".flow-canvas .react-flow__pane");
const paletteItem = page
.locator(".palette-item")
.filter({
has: page.locator(`[data-component-symbol="${modelType}"]`),
})
.first();
await paletteItem.dragTo(pane, { targetPosition });
const node = page
.locator(
`.flow-canvas .react-flow__node[data-id^="${modelType}_"]`,
)
.last();
await expect(node).toBeVisible();
const nodeId = await node.getAttribute("data-id");
expect(nodeId).not.toBeNull();
return page.locator(
`.flow-canvas .react-flow__node[data-id="${nodeId}"]`,
);
}
async function expectSymbolAnchors(
node: Locator,
anchors: Array<{ portName: string; x: number; y: number }>,
) {
for (const anchor of anchors) {
const error = await node.evaluate((element, expected) => {
const svg = element.querySelector<SVGSVGElement>(".component-symbol-svg");
const handle = element.querySelector<HTMLElement>(
`.port-handle[data-port-name="${expected.portName}"]`,
);
const matrix = svg?.getScreenCTM();
if (!svg || !handle || !matrix) {
return Number.POSITIVE_INFINITY;
}
const point = svg.createSVGPoint();
point.x = expected.x;
point.y = expected.y;
const screenPoint = point.matrixTransform(matrix);
const handleBounds = handle.getBoundingClientRect();
return Math.hypot(
screenPoint.x - (handleBounds.left + handleBounds.width / 2),
screenPoint.y - (handleBounds.top + handleBounds.height / 2),
);
}, anchor);
expect(error).toBeLessThan(2.5);
}
}
async function expectPortIndexLabels(
node: Locator,
expected: Record<string, string>,
) {
const labels = node.locator(".port-index-label");
await expect(labels).toHaveCount(Object.keys(expected).length);
for (const [portName, text] of Object.entries(expected)) {
const label = node.locator(
`.port-index-label[data-port-name="${portName}"]`,
);
await expect(label).toHaveCount(1);
await expect(label).toHaveText(text);
await expect(label).toHaveAttribute("data-port-index", text);
}
const firstLabel = labels.first();
await expect(firstLabel).toHaveCSS("font-size", "18px");
await expect(firstLabel).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(firstLabel).toHaveCSS("border-top-style", "none");
await expect(firstLabel).toHaveCSS("box-shadow", "none");
await expect(firstLabel).toHaveCSS("pointer-events", "none");
}
async function portIndexLabelLayout(node: Locator) {
return node.evaluate((element) => {
const labels = Array.from(
element.querySelectorAll<HTMLElement>(".port-index-label"),
).map((label) => {
const portName = label.dataset.portName ?? "";
const handle = element.querySelector<HTMLElement>(
`.port-handle[data-port-name="${portName}"]`,
);
const bounds = label.getBoundingClientRect();
const handleBounds = handle?.getBoundingClientRect();
const labelCenter = {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
};
const handleCenter = handleBounds
? {
x: handleBounds.left + handleBounds.width / 2,
y: handleBounds.top + handleBounds.height / 2,
}
: null;
const outward =
handle && handleCenter
? handle.classList.contains("react-flow__handle-left")
? labelCenter.x < handleCenter.x
: handle.classList.contains("react-flow__handle-right")
? labelCenter.x > handleCenter.x
: handle.classList.contains("react-flow__handle-top")
? labelCenter.y < handleCenter.y
: labelCenter.y > handleCenter.y
: false;
return {
bounds: {
bottom: bounds.bottom,
left: bounds.left,
right: bounds.right,
top: bounds.top,
},
distance: handleBounds
? Math.hypot(
labelCenter.x - handleCenter!.x,
labelCenter.y - handleCenter!.y,
)
: Number.POSITIVE_INFINITY,
outward,
};
});
let overlaps = 0;
for (let firstIndex = 0; firstIndex < labels.length; firstIndex += 1) {
for (
let secondIndex = firstIndex + 1;
secondIndex < labels.length;
secondIndex += 1
) {
const first = labels[firstIndex].bounds;
const second = labels[secondIndex].bounds;
if (
Math.min(first.right, second.right) >
Math.max(first.left, second.left) &&
Math.min(first.bottom, second.bottom) >
Math.max(first.top, second.top)
) {
overlaps += 1;
}
}
}
return {
maximumDistance: Math.max(...labels.map((label) => label.distance)),
minimumDistance: Math.min(...labels.map((label) => label.distance)),
overlaps,
outwardViolations: labels.filter((label) => !label.outward).length,
};
});
}
async function locatorCenter(locator: Locator) {
const bounds = await locator.boundingBox();
expect(bounds).not.toBeNull();
return {
x: bounds!.x + bounds!.width / 2,
y: bounds!.y + bounds!.height / 2,
};
}
async function distanceBetween(first: Locator, second: Locator) {
const [firstCenter, secondCenter] = await Promise.all([
locatorCenter(first),
locatorCenter(second),
]);
return Math.hypot(
secondCenter.x - firstCenter.x,
secondCenter.y - firstCenter.y,
);
}
async function dragNodePortNearTarget(
page: Page,
movingNode: Locator,
movingPort: Locator,
targetPort: Locator,
remainingGap = 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: "0.1.0",
symbol: item.modelType,
order: index + 1,
category: {
id: item.category,
label: categoryLabels[item.category],
order: Object.keys(categoryLabels).indexOf(item.category) + 1,
},
ports: portsForSymbol(item.modelType),
parameters:
item.modelType === "amesim_mecmas21"
? [
{
name: "useFriction",
label: "启用摩擦",
quantity: "dimensionless",
unit: "",
default: 1,
minimum: 1,
maximum: 2,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "否" },
{ value: 2, label: "是" },
],
},
{
name: "stoptype",
label: "限位类型",
quantity: "dimensionless",
unit: "",
default: 4,
minimum: 1,
maximum: 4,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "理想限位" },
{ value: 2, label: "弹性限位" },
{ value: 3, label: "恢复碰撞" },
{ value: 4, label: "无限位" },
],
},
]
: [],
})),
},
],
}),
});
});
});
test("全部已移植 AMESim 模型使用专属图标及正确领域配色", async ({ page }) => {
await page.goto("/");
const palette = page.locator("aside.palette");
await expect(palette.locator('[data-component-symbol^="amesim_"]')).toHaveCount(
symbolCases.length,
);
await expect(palette.locator('[data-component-symbol="generic"]')).toHaveCount(0);
for (const item of symbolCases) {
const symbol = palette.locator(`[data-component-symbol="${item.modelType}"]`);
await expect(symbol).toHaveCount(1);
const svg = symbol.locator("svg");
await expect(svg).toHaveAttribute("data-symbol-domain", item.domain);
await expect(svg).toHaveAttribute(
"data-symbol-stroke-scaling",
"independent",
);
await expect(symbol).toHaveAttribute(
"data-symbol-presentation",
item.domain === "medium" ? "bare" : "framed",
);
const registeredViewBox = await symbol.getAttribute("data-symbol-view-box");
expect(registeredViewBox).not.toBeNull();
await expect(svg).toHaveAttribute(
"viewBox",
registeredViewBox!,
);
const strokeScaling = await svg.evaluate((element) => {
const graphics = Array.from(
element.querySelectorAll<SVGElement>(
"path, line, polyline, polygon, rect, circle, ellipse, text",
),
);
const strokedGraphics = graphics.filter(
(graphic) => getComputedStyle(graphic).stroke !== "none",
);
return {
count: strokedGraphics.length,
independent: strokedGraphics.every(
(graphic) =>
getComputedStyle(graphic).vectorEffect === "non-scaling-stroke",
),
};
});
expect(strokeScaling.count).toBeGreaterThan(0);
expect(strokeScaling.independent).toBe(true);
}
});
test("组件库按尺寸档位显示不同缩略图占比且不影响工作区", async ({ page }) => {
await page.goto("/");
const paletteCases = [
{ modelType: "amesim_pnpl01", occupancy: 0.45, tier: "compact" },
{ modelType: "amesim_f000", occupancy: 0.45, tier: "compact" },
{ modelType: "amesim_pnch023", occupancy: 0.75, tier: "small" },
{ modelType: "amesim_pnch012", occupancy: 0.75, tier: "small" },
{ modelType: "amesim_pnrp17", occupancy: 0.9, tier: "standard" },
{ modelType: "amesim_lmechn1", occupancy: 0.9, tier: "custom" },
] as const;
for (const item of paletteCases) {
const symbol = page.locator(
`aside.palette [data-component-symbol="${item.modelType}"]`,
);
await expect(symbol).toHaveAttribute("data-symbol-size-tier", item.tier);
await expect(symbol).toHaveAttribute(
"data-palette-occupancy",
String(item.occupancy),
);
const geometry = await symbol
.locator("svg")
.evaluate((element: SVGSVGElement) => {
const bounds = element.getBBox();
const viewBox = element.viewBox.baseVal;
return {
centerOffsetX:
bounds.x + bounds.width / 2 - (viewBox.x + viewBox.width / 2),
centerOffsetY:
bounds.y + bounds.height / 2 - (viewBox.y + viewBox.height / 2),
occupancy:
Math.max(bounds.width, bounds.height) /
Math.max(viewBox.width, viewBox.height),
};
});
expect(geometry.occupancy).toBeCloseTo(item.occupancy, 3);
expect(geometry.centerOffsetX).toBeCloseTo(0, 3);
expect(geometry.centerOffsetY).toBeCloseTo(0, 3);
}
const compactOccupancies = await page
.locator('aside.palette [data-symbol-size-tier="compact"]')
.evaluateAll((elements) =>
elements.map((element) => {
const svg = element.querySelector("svg") as SVGSVGElement | null;
if (!svg) {
return 0;
}
const bounds = svg.getBBox();
const viewBox = svg.viewBox.baseVal;
return (
Math.max(bounds.width, bounds.height) /
Math.max(viewBox.width, viewBox.height)
);
}),
);
expect(compactOccupancies.length).toBeGreaterThan(1);
for (const occupancy of compactOccupancies) {
expect(occupancy).toBeCloseTo(0.45, 3);
}
const canvasSymbol = await dragSymbolToCanvas(page, "amesim_pnrp17", {
x: 430,
y: 280,
});
const canvasRatio = await canvasSymbol
.locator('[data-component-symbol="amesim_pnrp17"]')
.evaluate((element) => {
const symbolBounds = element.getBoundingClientRect();
const slotBounds = element.parentElement!.getBoundingClientRect();
return {
height: symbolBounds.height / slotBounds.height,
width: symbolBounds.width / slotBounds.width,
};
});
await expect(
canvasSymbol.locator('[data-component-symbol="amesim_pnrp17"]'),
).not.toHaveAttribute("data-palette-occupancy", /.+/);
expect(canvasRatio.width).toBeCloseTo(1, 2);
expect(canvasRatio.height).toBeCloseTo(1, 2);
});
test("介质物性组件统一使用 standard 画布、60% 图标占比和洋红文字", async ({
page,
}) => {
await page.goto("/");
const cases = [
{
label: "Air",
modelType: "amesim_ideal_air_medium",
target: { x: 300, y: 260 },
},
{
label: "He",
modelType: "amesim_helium_medium",
target: { x: 560, y: 260 },
},
] as const;
for (const item of cases) {
const paletteSymbol = page.locator(
`aside.palette [data-component-symbol="${item.modelType}"]`,
);
await expect(paletteSymbol).toHaveAttribute(
"data-symbol-size-tier",
"standard",
);
const node = await dragSymbolToCanvas(page, item.modelType, item.target);
const simNode = node.locator(".sim-node");
const symbol = node.locator(
`[data-component-symbol="${item.modelType}"]`,
);
const svg = symbol.locator("svg");
const circle = svg.locator("circle");
const label = svg.locator(`[data-symbol-label="${item.label}"]`);
await expect(simNode).toHaveAttribute("data-node-layout-tier", "standard");
await expect(simNode).toHaveAttribute("data-node-layout-width", "112");
await expect(simNode).toHaveAttribute("data-node-layout-height", "112");
await expect(symbol).toHaveAttribute("data-symbol-width", "112");
await expect(symbol).toHaveAttribute("data-symbol-height", "112");
await expect(svg).toHaveAttribute("viewBox", "0 -8 64 64");
await expect(circle).toHaveAttribute("r", "19.2");
await expect(circle).toHaveAttribute("stroke", "#8b134f");
await expect(circle).toHaveCSS("stroke", "rgb(139, 19, 79)");
await expect(label).toHaveAttribute("font-size", "13");
await expect(label).toHaveAttribute("font-weight", "600");
await expect(label).toHaveAttribute("fill", "#8b134f");
await expect(label).toHaveCSS("font-size", "13px");
await expect(label).toHaveCSS("font-weight", "600");
await expect(label).toHaveCSS("fill", "rgb(139, 19, 79)");
const occupancy = await svg.evaluate((element: SVGSVGElement) => {
const outline = element.querySelector("circle");
if (!outline) {
return 0;
}
const bounds = outline.getBBox();
const viewBox = element.viewBox.baseVal;
return Math.max(bounds.width, bounds.height) / Math.max(viewBox.width, viewBox.height);
});
expect(occupancy).toBeCloseTo(0.6, 4);
}
});
test("PNOR001 与两种 PNVO001 使用统一孔板风格和 small 画布", async ({
page,
}) => {
await page.goto("/");
const paletteFlowPath = page.locator(
'aside.palette [data-component-symbol="amesim_pnvo001_fixed"] [data-orifice-part="flow-path"]',
);
await expect(paletteFlowPath).toHaveCSS("stroke-width", "1.5px");
await expect(paletteFlowPath).toHaveCSS(
"vector-effect",
"non-scaling-stroke",
);
const cases = [
{
anchors: [
{ portName: "port_1", x: 12.8, y: 24 },
{ portName: "port_2", x: 51.2, y: 24 },
],
control: "none",
modelType: "amesim_pnor001",
target: { x: 220, y: 250 },
},
{
anchors: [
{ portName: "port_2", x: 12.8, y: 24 },
{ portName: "port_3", x: 51.2, y: 24 },
],
control: "fixed",
modelType: "amesim_pnvo001_fixed",
target: { x: 460, y: 250 },
},
{
anchors: [
{ portName: "res", x: 27.35, y: 43.2 },
{ portName: "port_2", x: 12.8, y: 24 },
{ portName: "port_3", x: 51.2, y: 24 },
],
control: "signal",
modelType: "amesim_pnvo001",
target: { x: 700, y: 250 },
},
] as const;
for (const item of cases) {
const node = await dragSymbolToCanvas(page, item.modelType, item.target);
const simNode = node.locator(".sim-node");
const symbol = node.locator(
`[data-component-symbol="${item.modelType}"]`,
);
const family = symbol.locator('[data-orifice-family="pnvo001"]');
const flowPath = family.locator('[data-orifice-part="flow-path"]');
const curves = family.locator('[data-orifice-part="curves"]');
const openingArrow = family.locator(
'[data-orifice-part="opening-arrow"]',
);
const signalControl = family.locator(
'[data-orifice-part="signal-control"]',
);
await expect(simNode).toHaveAttribute("data-node-layout-tier", "small");
const nodeBounds = await node.boundingBox();
expect(nodeBounds).not.toBeNull();
expect(Math.abs(nodeBounds!.width - 116)).toBeLessThan(1);
expect(Math.abs(nodeBounds!.height - 96)).toBeLessThan(1);
await expect(family).toHaveAttribute("data-orifice-control", item.control);
await expect(family).toHaveAttribute("transform", /scale\(0\.6\)/);
await expect(flowPath).toHaveAttribute("stroke-width", "1.5");
await expect(flowPath).toHaveAttribute("stroke", "#8b134f");
await expect(flowPath).toHaveCSS("stroke-width", "1.5px");
await expect(flowPath).toHaveCSS("vector-effect", "non-scaling-stroke");
await expect(curves).toHaveAttribute("stroke-width", "2.5");
await expect(curves).toHaveCSS("stroke-width", "2.5px");
await expect(curves).toHaveCSS("vector-effect", "non-scaling-stroke");
await expect(openingArrow).toHaveCount(item.control === "none" ? 0 : 1);
if (item.control !== "none") {
await expect(openingArrow).toHaveAttribute("stroke-width", "1.5");
await expect(openingArrow).toHaveCSS("stroke-width", "1.5px");
await expect(openingArrow).toHaveCSS(
"vector-effect",
"non-scaling-stroke",
);
}
await expect(signalControl).toHaveCount(item.control === "signal" ? 1 : 0);
if (item.control === "signal") {
await expect(signalControl).toHaveAttribute("stroke-width", "1.5");
await expect(signalControl).toHaveCSS("stroke-width", "1.5px");
await expect(signalControl).toHaveCSS(
"vector-effect",
"non-scaling-stroke",
);
}
const [familyBounds, symbolBounds, envelopeBounds] = await Promise.all([
family.boundingBox(),
symbol.boundingBox(),
node.locator('[data-symbol-envelope="true"]').boundingBox(),
]);
expect(familyBounds).not.toBeNull();
expect(symbolBounds).not.toBeNull();
expect(envelopeBounds).not.toBeNull();
expect(familyBounds!.width / symbolBounds!.width).toBeGreaterThan(0.58);
expect(familyBounds!.width / symbolBounds!.width).toBeLessThan(0.63);
expect(envelopeBounds!.x).toBeLessThanOrEqual(familyBounds!.x);
expect(envelopeBounds!.y).toBeLessThanOrEqual(familyBounds!.y);
expect(envelopeBounds!.x + envelopeBounds!.width).toBeGreaterThanOrEqual(
familyBounds!.x + familyBounds!.width,
);
expect(envelopeBounds!.y + envelopeBounds!.height).toBeGreaterThanOrEqual(
familyBounds!.y + familyBounds!.height,
);
expect(envelopeBounds!.width - familyBounds!.width).toBeGreaterThan(3);
expect(envelopeBounds!.width - familyBounds!.width).toBeLessThan(9);
expect(envelopeBounds!.height - familyBounds!.height).toBeGreaterThan(3);
expect(envelopeBounds!.height - familyBounds!.height).toBeLessThan(9);
if (item.control !== "none") {
const aspectRatio = familyBounds!.width / familyBounds!.height;
expect(aspectRatio).toBeGreaterThan(0.95);
expect(aspectRatio).toBeLessThan(1.05);
const envelopeAspectRatio =
envelopeBounds!.width / envelopeBounds!.height;
expect(envelopeAspectRatio).toBeGreaterThan(0.95);
expect(envelopeAspectRatio).toBeLessThan(1.05);
}
await expectSymbolAnchors(node, [...item.anchors]);
}
});
test("PNPL01 保持 compact 25% 叉形主体并延长接口引线", async ({
page,
}) => {
await page.goto("/");
const node = await dragSymbolToCanvas(page, "amesim_pnpl01", {
x: 300,
y: 240,
});
const simNode = node.locator(".sim-node");
const symbol = node.locator('[data-component-symbol="amesim_pnpl01"]');
const svg = symbol.locator("svg");
const body = svg.locator('[data-pnpl01-part="body"]');
const extension = svg.locator('[data-pnpl01-part="extension"]');
const cross = svg.locator('[data-pnpl01-part="cross"]');
const handle = node.locator('.port-handle[data-port-name="port_1"]');
await expect(simNode).toHaveAttribute("data-node-layout-tier", "compact");
await expect(body).toHaveAttribute("transform", /scale\(0\.25\)/);
await expect(extension).toHaveAttribute("stroke-width", "1.5");
await expect(extension).toHaveAttribute("stroke", "#8b134f");
await expect(extension).toHaveCSS("stroke-width", "1.5px");
await expect(extension).toHaveAttribute("d", "M16 24H35.75");
await expect(cross).toHaveAttribute("stroke-width", "4");
await expect(cross).toHaveAttribute("stroke", "#8b134f");
await expect(cross).toHaveCSS("stroke-width", "4px");
await expect(extension).toHaveCSS("vector-effect", "non-scaling-stroke");
await expect(cross).toHaveCSS("vector-effect", "non-scaling-stroke");
await expectSymbolAnchors(node, [{ portName: "port_1", x: 16, y: 24 }]);
const [nodeBounds, contentGeometry] = await Promise.all([
node.boundingBox(),
body.evaluate((element: SVGGElement) => {
const bounds = element.getBBox();
const matrix = element.transform.baseVal.consolidate()?.matrix;
return {
height: bounds.height,
scale: matrix?.a ?? 1,
width: bounds.width,
};
}),
]);
expect(nodeBounds).not.toBeNull();
expect(Math.abs(nodeBounds!.width - 100)).toBeLessThan(1);
expect(Math.abs(nodeBounds!.height - 80)).toBeLessThan(1);
expect(contentGeometry.width).toBeCloseTo(34, 4);
expect(contentGeometry.height).toBeCloseTo(34, 4);
expect(contentGeometry.scale).toBeCloseTo(0.25, 4);
const [
crossBounds,
envelopeBounds,
symbolBounds,
extensionBounds,
handleBounds,
] = await Promise.all([
cross.boundingBox(),
node.locator('[data-symbol-envelope="true"]').boundingBox(),
symbol.boundingBox(),
extension.boundingBox(),
handle.boundingBox(),
]);
expect(crossBounds).not.toBeNull();
expect(envelopeBounds).not.toBeNull();
expect(symbolBounds).not.toBeNull();
expect(extensionBounds).not.toBeNull();
expect(handleBounds).not.toBeNull();
expect(extensionBounds!.x + extensionBounds!.width).toBeGreaterThan(
handleBounds!.x + handleBounds!.width + 12,
);
expect(Math.abs(crossBounds!.width - crossBounds!.height)).toBeLessThan(0.5);
const crossCenter = {
x: crossBounds!.x + crossBounds!.width / 2,
y: crossBounds!.y + crossBounds!.height / 2,
};
expect(crossCenter.x).toBeGreaterThan(envelopeBounds!.x);
expect(crossCenter.x).toBeLessThan(
envelopeBounds!.x + envelopeBounds!.width,
);
expect(crossCenter.y).toBeGreaterThan(envelopeBounds!.y);
expect(crossCenter.y).toBeLessThan(
envelopeBounds!.y + envelopeBounds!.height,
);
expect(envelopeBounds!.width / symbolBounds!.width).toBeLessThan(0.5);
expect(envelopeBounds!.height / symbolBounds!.height).toBeLessThan(0.5);
});
test("F000 保持 compact 25% 方框主体并延长接口引线", async ({ page }) => {
await page.goto("/");
const node = await dragSymbolToCanvas(page, "amesim_f000", {
x: 300,
y: 240,
});
const simNode = node.locator(".sim-node");
const symbol = node.locator('[data-component-symbol="amesim_f000"]');
const body = symbol.locator('[data-f000-part="body"]');
const frame = symbol.locator('[data-f000-part="frame"]');
const extension = symbol.locator('[data-f000-part="extension"]');
const handle = node.locator('.port-handle[data-port-name="port_1"]');
await expect(simNode).toHaveAttribute("data-node-layout-tier", "compact");
await expect(body).toHaveAttribute("transform", /scale\(0\.25\)/);
await expect(frame).toHaveAttribute("stroke-width", "2.5");
await expect(frame).toHaveCSS("stroke-width", "2.5px");
await expect(extension).toHaveAttribute("stroke-width", "1.5");
await expect(extension).toHaveCSS("stroke-width", "1.5px");
await expect(extension).toHaveAttribute("d", "M37 24H48");
await expect(frame).toHaveCSS("vector-effect", "non-scaling-stroke");
await expect(extension).toHaveCSS("vector-effect", "non-scaling-stroke");
await expectSymbolAnchors(node, [{ portName: "port_1", x: 48, y: 24 }]);
const [nodeBounds, geometry] = await Promise.all([
node.boundingBox(),
body.evaluate((element: SVGGElement) => {
const bounds = element.getBBox();
const matrix = element.transform.baseVal.consolidate()?.matrix;
return {
height: bounds.height,
scale: matrix?.a ?? 1,
width: bounds.width,
};
}),
]);
expect(nodeBounds).not.toBeNull();
expect(Math.abs(nodeBounds!.width - 100)).toBeLessThan(1);
expect(Math.abs(nodeBounds!.height - 80)).toBeLessThan(1);
expect(geometry.width).toBeCloseTo(52, 4);
expect(geometry.height).toBeCloseTo(34, 4);
expect(geometry.scale).toBeCloseTo(0.25, 4);
const [extensionBounds, handleBounds] = await Promise.all([
extension.boundingBox(),
handle.boundingBox(),
]);
expect(extensionBounds).not.toBeNull();
expect(handleBounds).not.toBeNull();
expect(handleBounds!.x - extensionBounds!.x).toBeGreaterThan(6);
});
test("STEP0 与 UD00 使用 standard 画布并在工作区占比 60%", async ({
page,
}) => {
await page.goto("/");
const cases = [
{ modelType: "amesim_step0", source: "step0", x: 320 },
{ modelType: "amesim_ud00", source: "ud00", x: 560 },
] as const;
for (const item of cases) {
const node = await dragSymbolToCanvas(page, item.modelType, {
x: item.x,
y: 260,
});
const simNode = node.locator(".sim-node");
const symbol = node.locator(
`[data-component-symbol="${item.modelType}"]`,
);
const content = symbol.locator(`[data-signal-source="${item.source}"]`);
await expect(simNode).toHaveAttribute("data-node-layout-tier", "standard");
await expect(content).toHaveAttribute("transform", /scale\(0\.75294/);
await expectSymbolAnchors(node, [{ portName: "out", x: 51.2, y: 24 }]);
const [nodeBounds, symbolBounds, contentGeometry] = await Promise.all([
node.boundingBox(),
symbol.boundingBox(),
content.evaluate((element: SVGGElement) => {
const bounds = element.getBBox();
const matrix = element.transform.baseVal.consolidate()?.matrix;
return {
height: bounds.height,
scale: matrix?.a ?? 1,
width: bounds.width,
};
}),
]);
expect(nodeBounds).not.toBeNull();
expect(symbolBounds).not.toBeNull();
expect(Math.abs(nodeBounds!.width - 132)).toBeLessThan(1);
expect(Math.abs(nodeBounds!.height - 112)).toBeLessThan(1);
expect(contentGeometry.width).toBeCloseTo(51, 4);
expect(contentGeometry.height).toBeCloseTo(36, 4);
expect(
(contentGeometry.width * contentGeometry.scale) / 64,
).toBeCloseTo(0.6, 4);
const contentBounds = await content.boundingBox();
expect(contentBounds).not.toBeNull();
const visibleOccupancy = contentBounds!.width / symbolBounds!.width;
expect(visibleOccupancy).toBeGreaterThan(0.6);
// 独立描边不会随主体缩放,含描边的可见包络会略大于 60%。
expect(visibleOccupancy).toBeLessThan(0.63);
}
});
test("FORC 按参考图使用 standard 画布并在工作区占比 60%", async ({
page,
}) => {
await page.goto("/");
const node = await dragSymbolToCanvas(page, "amesim_forc", {
x: 420,
y: 280,
});
const simNode = node.locator(".sim-node");
const symbol = node.locator('[data-component-symbol="amesim_forc"]');
const content = symbol.locator('[data-forc-part="symbol"]');
const thickParts = symbol.locator(
'[data-forc-part="input-arrow"], [data-forc-part="force-circle"], [data-forc-part="mechanical-socket"]',
);
const thinParts = symbol.locator(
'[data-forc-part="left-extension"], [data-forc-part="right-extension"]',
);
const label = symbol.locator('[data-forc-part="label"]');
const circle = symbol.locator('[data-forc-part="force-circle"]');
const leftExtension = symbol.locator('[data-forc-part="left-extension"]');
const rightExtension = symbol.locator('[data-forc-part="right-extension"]');
const leftHandle = node.locator('.port-handle[data-port-name="res"]');
const rightHandle = node.locator('.port-handle[data-port-name="port_2"]');
await expect(simNode).toHaveAttribute("data-node-layout-tier", "standard");
await expect(content).toHaveAttribute("transform", /scale\(0\.64\)/);
await expect(thickParts).toHaveCount(3);
for (let index = 0; index < (await thickParts.count()); index += 1) {
await expect(thickParts.nth(index)).toHaveCSS("stroke-width", "2px");
await expect(thickParts.nth(index)).toHaveCSS(
"vector-effect",
"non-scaling-stroke",
);
}
await expect(thinParts).toHaveCount(2);
for (let index = 0; index < (await thinParts.count()); index += 1) {
await expect(thinParts.nth(index)).toHaveCSS("stroke-width", "1.5px");
await expect(thinParts.nth(index)).toHaveCSS(
"vector-effect",
"non-scaling-stroke",
);
}
await expect(label).toHaveText("F");
await expect(label).toHaveAttribute("font-size", "15");
await expect(label).toHaveCSS("font-weight", "600");
await expectSymbolAnchors(node, [
{ portName: "res", x: 12.8, y: 24 },
{ portName: "port_2", x: 51.2, y: 24 },
]);
const [
nodeBounds,
symbolBounds,
contentGeometry,
circleBounds,
labelBounds,
leftExtensionBounds,
rightExtensionBounds,
leftHandleBounds,
rightHandleBounds,
] = await Promise.all([
node.boundingBox(),
symbol.boundingBox(),
content.evaluate((element: SVGGElement) => {
const bounds = element.getBBox();
const matrix = element.transform.baseVal.consolidate()?.matrix;
return {
height: bounds.height,
scale: matrix?.a ?? 1,
width: bounds.width,
};
}),
circle.boundingBox(),
label.boundingBox(),
leftExtension.boundingBox(),
rightExtension.boundingBox(),
leftHandle.boundingBox(),
rightHandle.boundingBox(),
]);
expect(nodeBounds).not.toBeNull();
expect(symbolBounds).not.toBeNull();
expect(circleBounds).not.toBeNull();
expect(labelBounds).not.toBeNull();
expect(leftExtensionBounds).not.toBeNull();
expect(rightExtensionBounds).not.toBeNull();
expect(leftHandleBounds).not.toBeNull();
expect(rightHandleBounds).not.toBeNull();
expect(Math.abs(nodeBounds!.width - 132)).toBeLessThan(1);
expect(Math.abs(nodeBounds!.height - 112)).toBeLessThan(1);
expect(contentGeometry.width).toBeCloseTo(60, 4);
expect(contentGeometry.scale).toBeCloseTo(0.64, 4);
expect(
(contentGeometry.width * contentGeometry.scale) / 64,
).toBeCloseTo(0.6, 4);
expect(labelBounds!.height / circleBounds!.height).toBeGreaterThan(0.58);
expect(labelBounds!.height / circleBounds!.height).toBeLessThan(0.68);
expect(leftExtensionBounds!.x + leftExtensionBounds!.width).toBeGreaterThan(
leftHandleBounds!.x + leftHandleBounds!.width + 8,
);
expect(rightHandleBounds!.x - rightExtensionBounds!.x).toBeGreaterThan(4);
const contentBounds = await content.boundingBox();
expect(contentBounds).not.toBeNull();
expect(contentBounds!.width / symbolBounds!.width).toBeGreaterThan(0.59);
expect(contentBounds!.width / symbolBounds!.width).toBeLessThan(0.62);
});
test("PNCH023 与 PNCH012 使用相同的气室主体尺寸", async ({ page }) => {
await page.goto("/");
const fixedNode = await dragSymbolToCanvas(page, "amesim_pnch023", {
x: 300,
y: 260,
});
const variableNode = await dragSymbolToCanvas(page, "amesim_pnch012", {
x: 540,
y: 260,
});
const fixedSymbol = fixedNode.locator(
'[data-component-symbol="amesim_pnch023"]',
);
const variableSymbol = variableNode.locator(
'[data-component-symbol="amesim_pnch012"]',
);
const fixedContent = fixedSymbol.locator('[data-pnch023-part="symbol"]');
const variableContent = variableSymbol.locator(
'[data-pnch012-part="symbol"]',
);
const fixedLabel = fixedSymbol.locator('[data-pnch023-part="label"]');
await expect(fixedNode.locator(".sim-node")).toHaveAttribute(
"data-node-layout-tier",
"small",
);
await expect(fixedContent).toHaveAttribute("transform", /scale\(0\.45\)/);
await expect(
fixedSymbol.locator('[data-pnch023-part="chamber"]'),
).toHaveAttribute("r", "21");
await expect(fixedSymbol.locator('[data-pnch023-part="ports"]')).toHaveCSS(
"stroke-width",
"2px",
);
await expect(fixedLabel).toHaveText("Cp");
await expect(fixedLabel).toHaveAttribute("fill", "#8b134f");
await expect(fixedLabel).toHaveCSS("font-weight", "600");
await expect(fixedLabel).toHaveCSS("stroke", "none");
expect(await fixedLabel.getAttribute("stroke-width")).toBeNull();
await expect(fixedContent.locator('[data-pnch023-part="label"]')).toHaveCount(
0,
);
const renderedLabelSize = await fixedLabel.evaluate(
(element: SVGTextElement) => {
const svg = element.ownerSVGElement;
if (!svg) {
return 0;
}
const fontSize = Number.parseFloat(getComputedStyle(element).fontSize);
const viewBoxScale =
svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
return fontSize * viewBoxScale;
},
);
expect(renderedLabelSize).toBeCloseTo(15, 1);
await expectSymbolAnchors(fixedNode, [
{ portName: "port_1", x: 17.6, y: 24 },
{ portName: "port_2", x: 46.4, y: 24 },
]);
const [fixedNodeBounds, fixedContentBounds, variableContentBounds] =
await Promise.all([
fixedNode.boundingBox(),
fixedContent.boundingBox(),
variableContent.boundingBox(),
]);
expect(fixedNodeBounds).not.toBeNull();
expect(fixedContentBounds).not.toBeNull();
expect(variableContentBounds).not.toBeNull();
expect(Math.abs(fixedNodeBounds!.width - 116)).toBeLessThan(1);
expect(Math.abs(fixedNodeBounds!.height - 96)).toBeLessThan(1);
expect(
Math.abs(fixedContentBounds!.width - variableContentBounds!.width),
).toBeLessThan(3);
});
test("PNCH012 按参考图使用 45% 结构与独立 15px 文字", async ({
page,
}) => {
await page.goto("/");
const node = await dragSymbolToCanvas(page, "amesim_pnch012", {
x: 360,
y: 260,
});
const simNode = node.locator(".sim-node");
const symbol = node.locator('[data-component-symbol="amesim_pnch012"]');
const svg = symbol.locator("svg");
const content = svg.locator('[data-pnch012-part="symbol"]');
const structuralParts = svg.locator(
'[data-pnch012-part="ports"], [data-pnch012-part="chamber"], [data-pnch012-part="volume-arrow"]',
);
const chamber = svg.locator('[data-pnch012-part="chamber"]');
const arrows = svg.locator('[data-pnch012-part="volume-arrow"]');
const label = svg.locator('[data-pnch012-part="label"]');
await expect(simNode).toHaveAttribute("data-node-layout-tier", "small");
await expect(content).toHaveAttribute("transform", /scale\(0\.45\)/);
await expect(chamber).toHaveAttribute("r", "21");
await expect(arrows).toHaveCount(4);
await expect(structuralParts).toHaveCount(6);
for (let index = 0; index < (await structuralParts.count()); index += 1) {
const part = structuralParts.nth(index);
await expect(part).toHaveCSS("stroke-width", "1.5px");
await expect(part).toHaveCSS("vector-effect", "non-scaling-stroke");
}
await expect(label).toHaveText("Cp");
await expect(label).toHaveAttribute("font-weight", "600");
await expect(label).toHaveCSS("font-weight", "600");
await expect(label).toHaveCSS("stroke", "none");
expect(await label.getAttribute("stroke-width")).toBeNull();
await expect(label).toHaveCSS("vector-effect", "non-scaling-stroke");
await expect(content.locator('[data-pnch012-part="label"]')).toHaveCount(0);
await expect(svg.locator(':scope > [data-pnch012-part="label"]')).toHaveCount(
1,
);
const renderedLabelSize = await label.evaluate((element: SVGTextElement) => {
const svg = element.ownerSVGElement;
if (!svg) {
return 0;
}
const fontSize = Number.parseFloat(getComputedStyle(element).fontSize);
const viewBoxScale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
return fontSize * viewBoxScale;
});
expect(renderedLabelSize).toBeCloseTo(15, 1);
await expectSymbolAnchors(node, [
{ portName: "port_1", x: 17.6, y: 24 },
{ portName: "port_3", x: 32, y: 9.6 },
{ portName: "port_2", x: 46.4, y: 24 },
{ portName: "port_4", x: 32, y: 38.4 },
]);
const nodeBounds = await node.boundingBox();
expect(nodeBounds).not.toBeNull();
expect(Math.abs(nodeBounds!.width - 116)).toBeLessThan(1);
expect(Math.abs(nodeBounds!.height - 96)).toBeLessThan(1);
const contentGeometry = await content.evaluate((element: SVGGElement) => {
const bounds = element.getBBox();
const matrix = element.transform.baseVal.consolidate()?.matrix;
return {
height: bounds.height,
scale: matrix?.a ?? 1,
width: bounds.width,
};
});
expect(contentGeometry.width).toBeCloseTo(64, 4);
expect(contentGeometry.height).toBeCloseTo(64, 4);
expect(contentGeometry.scale).toBeCloseTo(0.45, 4);
expect((contentGeometry.width * contentGeometry.scale) / 64).toBeCloseTo(
0.45,
4,
);
});
test("不同尺寸档位使用紧凑节点框且旋转时保持中心", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const paneBounds = await page.locator(".flow-canvas .react-flow__pane").boundingBox();
expect(paneBounds).not.toBeNull();
const cases = [
{
modelType: "amesim_pnor001",
size: { width: 116, height: 96 },
target: { x: 130, y: 150 },
tier: "small",
},
{
modelType: "amesim_pnl00r",
size: { width: 100, height: 80 },
target: { x: 360, y: 150 },
tier: "compact",
},
{
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: 128 },
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 compactNode = nodes.get("amesim_pnl00r") as Locator;
await compactNode.locator('[data-symbol-envelope="true"]').click();
const beforeRotation = await compactNode.boundingBox();
expect(beforeRotation).not.toBeNull();
await page
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
.click();
const afterRotation = await compactNode.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-left[data-port-name^="port_"]')
.evaluateAll((elements) =>
elements
.map((element) => {
const bounds = element.getBoundingClientRect();
return { top: bounds.top, bottom: bounds.bottom };
})
.sort((first, second) => first.top - second.top),
);
for (let index = 1; index < denseHandles.length; index += 1) {
expect(denseHandles[index].top).toBeGreaterThanOrEqual(
denseHandles[index - 1].bottom - 0.5,
);
}
await page.getByRole("button", { name: "开启网格吸附", exact: true }).click();
const snappedTarget = { x: 600, y: 610 };
const snappedNode = await dragSymbolToCanvas(
page,
"amesim_f000",
snappedTarget,
);
const snappedBounds = await snappedNode.boundingBox();
expect(snappedBounds).not.toBeNull();
expect(
Math.abs(
snappedBounds!.x + snappedBounds!.width / 2 -
(paneBounds!.x + snappedTarget.x),
),
).toBeLessThanOrEqual(10);
expect(
Math.abs(
snappedBounds!.y + snappedBounds!.height / 2 -
(paneBounds!.y + snappedTarget.y),
),
).toBeLessThanOrEqual(10);
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem(
"system-simulation-flow:autosave",
);
const project = raw ? JSON.parse(raw) : null;
const position = project?.nodes?.at(-1)?.position;
return position
? { xRemainder: position.x % 18, yRemainder: position.y % 18 }
: null;
}),
)
.toEqual({ xRemainder: 0, yRemainder: 0 });
});
test("MECMAS21 图标按摩擦和限位参数切换四种参考外观", async ({ page }) => {
await page.goto("/");
const node = await dragSymbolToCanvas(page, "amesim_mecmas21", {
x: 430,
y: 300,
});
const symbol = node.locator(
'[data-component-symbol="amesim_mecmas21"]',
);
const state = symbol.locator("[data-mecmas-variant]");
const core = symbol.locator('[data-mecmas-part="core"]');
const 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\)/);
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: "disabled",
guides: false,
limits: "unlimited",
lowerGuide: "none",
variant: "plain-unlimited",
});
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 useFriction.selectOption("2");
await expectVariant({
friction: "enabled",
guides: true,
limits: "unlimited",
lowerGuide: "unlimited",
variant: "friction-unlimited",
});
await stoptype.selectOption("2");
await expectVariant({
friction: "enabled",
guides: true,
limits: "finite",
lowerGuide: "finite",
variant: "friction-limited",
});
await useFriction.selectOption("1");
await expectVariant({
friction: "disabled",
guides: true,
limits: "finite",
lowerGuide: "finite",
variant: "plain-limited",
});
await stoptype.selectOption("4");
await expectVariant({
friction: "disabled",
guides: false,
limits: "unlimited",
lowerGuide: "none",
variant: "plain-unlimited",
});
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: 48 },
{ portName: "port_3", x: 2, y: 4.4 },
{ portName: "port_4", x: 2, y: 24.1 },
{ portName: "port_2", x: 62, y: 4.4 },
{ portName: "port_5", x: 62, y: 24.1 },
]);
await expect(
piston.locator('.port-handle[data-port-name="port_1"]'),
).toHaveClass(/react-flow__handle-bottom/);
const pnrp17Svg = piston.locator(
'[data-component-symbol="amesim_pnrp17"] svg',
);
await expect(pnrp17Svg.locator('[stroke-width="2"]')).toHaveCount(7);
await expect(pnrp17Svg.locator('g[stroke-width="1.5"]')).toHaveCount(2);
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.5px": 8, "2px": 7 });
});
test("选中元件时按接口编号显示固定且不重叠的数字标记", async ({
page,
}) => {
await page.goto("/");
const mecmas = await dragSymbolToCanvas(page, "amesim_mecmas21", {
x: 260,
y: 220,
});
await expectPortIndexLabels(mecmas, { port_1: "1", port_2: "2" });
let layout = await portIndexLabelLayout(mecmas);
expect(layout.overlaps).toBe(0);
expect(layout.outwardViolations).toBe(0);
expect(layout.minimumDistance).toBeGreaterThan(18);
expect(layout.maximumDistance).toBeLessThan(52);
const signalOrifice = await dragSymbolToCanvas(page, "amesim_pnvo001", {
x: 520,
y: 220,
});
await expect(mecmas.locator(".port-index-label")).toHaveCount(0);
await expectPortIndexLabels(signalOrifice, {
res: "1",
port_2: "2",
port_3: "3",
});
layout = await portIndexLabelLayout(signalOrifice);
expect(layout.overlaps).toBe(0);
expect(layout.outwardViolations).toBe(0);
expect(layout.maximumDistance).toBeLessThan(52);
const multiPortNode = await dragSymbolToCanvas(page, "amesim_lmechn1", {
x: 780,
y: 300,
});
await expect(signalOrifice.locator(".port-index-label")).toHaveCount(0);
await expectPortIndexLabels(
multiPortNode,
Object.fromEntries(
Array.from({ length: 9 }, (_, index) => [
`port_${index + 1}`,
String(index + 1),
]),
),
);
layout = await portIndexLabelLayout(multiPortNode);
expect(layout.overlaps).toBe(0);
expect(layout.outwardViolations).toBe(0);
expect(layout.minimumDistance).toBeGreaterThan(18);
expect(layout.maximumDistance).toBeLessThan(52);
await page
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
.click();
await page
.getByRole("button", { name: "水平镜像 (Shift+H)", exact: true })
.click();
await expectPortIndexLabels(
multiPortNode,
Object.fromEntries(
Array.from({ length: 9 }, (_, index) => [
`port_${index + 1}`,
String(index + 1),
]),
),
);
layout = await portIndexLabelLayout(multiPortNode);
expect(layout.overlaps).toBe(0);
expect(layout.outwardViolations).toBe(0);
expect(layout.maximumDistance).toBeLessThan(52);
const outsideLabelBounds = await multiPortNode
.locator('.port-index-label[data-port-name="port_9"]')
.boundingBox();
expect(outsideLabelBounds).not.toBeNull();
await page.mouse.click(
outsideLabelBounds!.x + outsideLabelBounds!.width / 2,
outsideLabelBounds!.y + outsideLabelBounds!.height / 2,
);
await expect(multiPortNode).not.toHaveClass(/selected/);
await expect(multiPortNode.locator(".port-index-label")).toHaveCount(0);
});
test("批量选中时隐藏已连接接口编号并保留未连接接口编号", 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("旧工程缺失接口标识时只修复唯一配对并拒绝歧义连接", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => {
const physicalPort = (name: string, side: "left" | "right") => ({
name,
kind: "physical",
domain: "pneumatic",
nominalRole: "bidirectional",
positiveFlowDirection: "intoComponent",
side,
});
const makeNode = (
id: string,
componentType: string,
x: number,
y: number,
ports: ReturnType<typeof physicalPort>[],
) => ({
id,
type: "simulationComponent",
position: { x, y },
data: {
label: id,
componentType,
modelType: componentType,
ports,
parameters: {},
parameterUnits: {},
parameterScientificNotation: {},
rotation: 0,
mirrored: false,
},
});
window.localStorage.setItem(
"system-simulation-flow:autosave",
JSON.stringify({
name: "edge-handle-recovery",
presentationLayoutVersion: 1,
nodes: [
makeNode(
"amesim_pnpl01_1",
"amesim_pnpl01",
180,
160,
[physicalPort(" port_1 ", "left")],
),
makeNode(
"amesim_pnpl01_2",
"amesim_pnpl01",
420,
160,
[physicalPort("port_1", "left")],
),
makeNode(
"amesim_pnor001_1",
"amesim_pnor001",
180,
360,
[physicalPort("port_1", "left"), physicalPort("port_2", "right")],
),
makeNode(
"amesim_pnor001_2",
"amesim_pnor001",
420,
360,
[physicalPort("port_1", "left"), physicalPort("port_2", "right")],
),
],
edges: [
{
id: "repairable-edge",
source: "amesim_pnpl01_1",
sourceHandle: null,
target: "amesim_pnpl01_2",
targetHandle: null,
},
{
id: "ambiguous-edge",
source: "amesim_pnor001_1",
sourceHandle: null,
target: "amesim_pnor001_2",
targetHandle: null,
},
],
simulation: {
t_start: 0,
t_stop: 2,
step: 0.1,
max_step: 0.005,
method: "BDF",
},
}),
);
});
await page.reload();
await page.getByRole("button", { name: "恢复", exact: true }).click();
const repairedNodes = ["amesim_pnpl01_1", "amesim_pnpl01_2"].map((id) =>
page.locator(`.flow-canvas .react-flow__node[data-id="${id}"]`),
);
const ambiguousNodes = ["amesim_pnor001_1", "amesim_pnor001_2"].map((id) =>
page.locator(`.flow-canvas .react-flow__node[data-id="${id}"]`),
);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
for (const node of repairedNodes) {
await expect(node.locator('.port-handle[data-port-name="port_1"]')).toHaveAttribute(
"data-connected",
"true",
);
}
for (const node of ambiguousNodes) {
await expect(node.locator('.port-handle[data-connected="true"]')).toHaveCount(0);
}
await page.keyboard.press("Control+A");
for (const node of repairedNodes) {
await expect(node).toHaveClass(/selected/);
await expect(node.locator(".port-index-label")).toHaveCount(0);
}
for (const node of ambiguousNodes) {
await expect(node).toHaveClass(/selected/);
await expectPortIndexLabels(node, { port_1: "1", port_2: "2" });
}
await expect(page.locator(".flow-canvas .react-flow__edge")).not.toHaveClass(
/selected|editor-edge-selected/,
);
});
test("AMESim canvas nodes use icon anchors and highlight only compatible free ports", async ({
page,
}) => {
await page.goto("/");
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 250,
y: 210,
});
const secondOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 610,
y: 210,
});
const stepSource = await dragSymbolToCanvas(page, "amesim_step0", {
x: 250,
y: 450,
});
const signalOrifice = await dragSymbolToCanvas(page, "amesim_pnvo001", {
x: 610,
y: 450,
});
await expect(firstOrifice.locator(".sim-node")).toHaveClass(
/component-icon-only/,
);
await expect(firstOrifice.locator(".node-label")).toBeHidden();
const frameStyle = await firstOrifice.locator(".sim-node-frame").evaluate(
(element) => ({
borderWidth: getComputedStyle(element).borderTopWidth,
background: getComputedStyle(element).backgroundColor,
}),
);
expect(frameStyle).toEqual({
borderWidth: "0px",
background: "rgba(0, 0, 0, 0)",
});
const firstOrificeAnchors = [
{ portName: "port_1", x: 12.8, y: 24 },
{ portName: "port_2", x: 51.2, y: 24 },
];
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
await expectSymbolAnchors(signalOrifice, [
{ portName: "res", x: 27.35, y: 43.2 },
{ portName: "port_2", x: 12.8, y: 24 },
{ portName: "port_3", x: 51.2, y: 24 },
]);
await firstOrifice.locator('[data-symbol-envelope="true"]').click();
await page
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
.click();
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
await page
.getByRole("button", { name: "水平镜像 (Shift+H)", exact: true })
.click();
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
const firstPhysicalOutput = firstOrifice.locator(
'.port-handle[data-port-name="port_2"]',
);
const sameNodePort = firstOrifice.locator(
'.port-handle[data-port-name="port_1"]',
);
const compatiblePhysicalPort = secondOrifice.locator(
'.port-handle[data-port-name="port_1"]',
);
const signalOutput = stepSource.locator(
'.port-handle[data-port-name="out"]',
);
const signalInput = signalOrifice.locator(
'.port-handle[data-port-name="res"]',
);
await expect(signalInput).toHaveClass(/react-flow__handle-bottom/);
const pneumaticInput = signalOrifice.locator(
'.port-handle[data-port-name="port_2"]',
);
await firstPhysicalOutput.click();
await expect(firstPhysicalOutput).toHaveAttribute(
"data-connection-state",
"origin",
);
await expect(compatiblePhysicalPort).toHaveAttribute(
"data-connection-state",
"compatible",
);
await expect(pneumaticInput).toHaveAttribute(
"data-connection-state",
"compatible",
);
await expect(sameNodePort).toHaveAttribute(
"data-connection-state",
"incompatible",
);
await expect(signalInput).toHaveAttribute(
"data-connection-state",
"incompatible",
);
await expect(sameNodePort).toHaveCSS("pointer-events", "none");
await expect(signalInput).toHaveCSS("pointer-events", "none");
await page.locator(".flow-canvas .react-flow__pane").click({
position: { x: 20, y: 20 },
});
await expect(firstPhysicalOutput).toHaveAttribute(
"data-connection-state",
"idle",
);
await compatiblePhysicalPort.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(compatiblePhysicalPort).toHaveAttribute(
"data-connection-state",
"origin",
);
await page.keyboard.press("Escape");
await expect(compatiblePhysicalPort).toHaveAttribute(
"data-connection-state",
"idle",
);
await firstPhysicalOutput.click();
await compatiblePhysicalPort.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true");
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
await expect(firstPhysicalOutput).toHaveCSS("opacity", "0");
await expect(compatiblePhysicalPort).toHaveCSS("opacity", "0");
await expect(firstPhysicalOutput).toHaveCSS("pointer-events", "none");
await expect(compatiblePhysicalPort).toHaveCSS("pointer-events", "none");
const physicalEdge = page.locator(".flow-canvas .react-flow__edge").first();
const edgeClickPoint = await physicalEdge
.locator(".react-flow__edge-interaction")
.evaluate((path: SVGPathElement) => {
const point = path.getPointAtLength(path.getTotalLength() / 2);
const matrix = path.getScreenCTM();
if (!matrix) {
throw new Error("Unable to resolve the edge screen position");
}
const screenPoint = new DOMPoint(point.x, point.y).matrixTransform(matrix);
return { x: screenPoint.x, y: screenPoint.y };
});
await page.mouse.click(edgeClickPoint.x, edgeClickPoint.y);
await expect(physicalEdge).toHaveClass(/selected|editor-edge-selected/);
await page.getByRole("button", { name: "删除 (Delete)", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "false");
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "false");
await expect(firstPhysicalOutput).toHaveCSS("opacity", "1");
await expect(compatiblePhysicalPort).toHaveCSS("opacity", "1");
await expect(firstPhysicalOutput).not.toHaveCSS("pointer-events", "none");
await expect(compatiblePhysicalPort).not.toHaveCSS("pointer-events", "none");
await firstPhysicalOutput.click();
await compatiblePhysicalPort.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
const signalOutputBounds = await signalOutput.boundingBox();
const signalInputBounds = await signalInput.boundingBox();
expect(signalOutputBounds).not.toBeNull();
expect(signalInputBounds).not.toBeNull();
await page.mouse.move(
signalOutputBounds!.x + signalOutputBounds!.width / 2,
signalOutputBounds!.y + signalOutputBounds!.height / 2,
);
await page.mouse.down();
await expect(signalOutput).toHaveAttribute(
"data-connection-state",
"origin",
);
await expect(signalInput).toHaveAttribute(
"data-connection-state",
"compatible",
);
const paneBounds = await page
.locator(".flow-canvas .react-flow__pane")
.boundingBox();
expect(paneBounds).not.toBeNull();
await page.mouse.move(paneBounds!.x + 20, paneBounds!.y + 20, { steps: 4 });
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__connection")).toHaveCount(0);
await expect(signalOutput).toHaveAttribute(
"data-connection-state",
"idle",
);
await expect(signalInput).toHaveAttribute(
"data-connection-state",
"idle",
);
await expect(pneumaticInput).toHaveAttribute(
"data-connection-state",
"idle",
);
const retriedSignalOutputBounds = await signalOutput.boundingBox();
const retriedSignalInputBounds = await signalInput.boundingBox();
expect(retriedSignalOutputBounds).not.toBeNull();
expect(retriedSignalInputBounds).not.toBeNull();
await page.mouse.move(
retriedSignalOutputBounds!.x + retriedSignalOutputBounds!.width / 2,
retriedSignalOutputBounds!.y + retriedSignalOutputBounds!.height / 2,
);
await page.mouse.down();
await expect(signalOutput).toHaveAttribute(
"data-connection-state",
"origin",
);
await page.mouse.move(
retriedSignalOutputBounds!.x + retriedSignalOutputBounds!.width / 2 + 12,
retriedSignalOutputBounds!.y + retriedSignalOutputBounds!.height / 2,
);
await expect(signalInput).toHaveAttribute(
"data-connection-state",
"compatible",
);
await expect(pneumaticInput).toHaveAttribute(
"data-connection-state",
"incompatible",
);
await page.mouse.move(
retriedSignalInputBounds!.x + retriedSignalInputBounds!.width / 2,
retriedSignalInputBounds!.y + retriedSignalInputBounds!.height / 2,
{ steps: 5 },
);
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
await expect(signalOutput).toHaveAttribute("data-connected", "true");
await expect(signalInput).toHaveAttribute("data-connected", "true");
});
test("兼容接口接近时吸附为无连线接触连接,拖开后自动断开", async ({
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 280,
y: 300,
});
const secondOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 650,
y: 300,
});
const firstPort = firstOrifice.locator(
'.port-handle[data-port-name="port_2"]',
);
const secondPort = secondOrifice.locator(
'.port-handle[data-port-name="port_1"]',
);
await dragNodePortNearTarget(
page,
secondOrifice,
secondPort,
firstPort,
8,
);
await expect(firstPort).toHaveAttribute("data-connection-state", "idle");
await expect(secondPort).toHaveAttribute("data-connection-state", "idle");
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(6);
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await dragNodePortNearTarget(
page,
secondOrifice,
secondPort,
firstPort,
);
await expect(firstPort).toHaveAttribute(
"data-connection-state",
"compatible",
);
await expect(secondPort).toHaveAttribute(
"data-connection-state",
"origin",
);
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(2);
await page.mouse.up();
await expect
.poll(() => distanceBetween(firstPort, secondPort))
.toBeLessThan(2);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
const contactHit = page.locator(".flow-canvas .contact-edge-hit");
await expect(contactHit).toHaveCount(1);
await expect(firstPort).toHaveAttribute("data-connected", "true");
await expect(secondPort).toHaveAttribute("data-connected", "true");
await expect(firstPort).toHaveCSS("opacity", "0");
await expect(secondPort).toHaveCSS("opacity", "0");
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
const xmlBlock = page.getByLabel("生成的系统 XML");
await expect(xmlBlock).toContainText('<Connection id="edge-contact-');
await expect(xmlBlock).toContainText('component="amesim_pnor001_1" port="port_2"');
await expect(xmlBlock).toContainText('component="amesim_pnor001_2" port="port_1"');
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const savedEdge = await page.evaluate(() => {
const raw = window.localStorage.getItem(
"system-simulation-flow:project:demo-system",
);
const project = raw ? JSON.parse(raw) : null;
return project?.edges?.[0] ?? null;
});
expect(savedEdge).toMatchObject({
source: "amesim_pnor001_2",
sourceHandle: "port_1",
target: "amesim_pnor001_1",
targetHandle: "port_2",
});
await contactHit.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveClass(
/editor-edge-selected/,
);
await page.getByRole("button", { name: "删除 (Delete)", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(firstPort).toHaveAttribute("data-connected", "false");
await expect(secondPort).toHaveAttribute("data-connected", "false");
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(8);
await dragNodePortNearTarget(
page,
secondOrifice,
secondPort,
firstPort,
5,
);
await expect(firstPort).toHaveAttribute(
"data-connection-state",
"compatible",
);
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
await page
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await page.getByRole("button", { name: "撤销 (Ctrl+Z)", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
const secondSymbolCenter = await locatorCenter(
secondOrifice.locator(".node-symbol"),
);
await page.mouse.move(secondSymbolCenter.x, secondSymbolCenter.y);
await page.mouse.down();
await page.mouse.move(secondSymbolCenter.x + 90, secondSymbolCenter.y + 35, {
steps: 10,
});
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(firstPort).toHaveAttribute("data-connected", "false");
await expect(secondPort).toHaveAttribute("data-connected", "false");
await expect(firstPort).toHaveCSS("opacity", "1");
await expect(secondPort).toHaveCSS("opacity", "1");
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(50);
});
test("一次拖动会吸附同一落点内的全部兼容接口", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const 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(2);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
for (const port of [
movingLeftPort,
movingRightPort,
leftTargetPort,
rightTargetPort,
]) {
await expect(port).toHaveAttribute("data-connected", "true");
}
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const savedEndpointKeys = await page.evaluate(() => {
const raw = window.localStorage.getItem(
"system-simulation-flow:project:demo-system",
);
const edges = raw ? (JSON.parse(raw)?.edges ?? []) : [];
return edges.flatMap(
(edge: {
source: string;
sourceHandle?: string;
target: string;
targetHandle?: string;
}) => [
`${edge.source}:${edge.sourceHandle ?? ""}`,
`${edge.target}:${edge.targetHandle ?? ""}`,
],
);
});
expect(savedEndpointKeys).toHaveLength(4);
expect(new Set(savedEndpointKeys)).toHaveProperty("size", 4);
});
test("不同类型接口接近时不会吸附或建立逻辑连接", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const orifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 280,
y: 320,
});
const stepSource = await dragSymbolToCanvas(page, "amesim_step0", {
x: 650,
y: 320,
});
const physicalPort = orifice.locator(
'.port-handle[data-port-name="port_2"]',
);
const signalPort = stepSource.locator(
'.port-handle[data-port-name="out"]',
);
await dragNodePortNearTarget(
page,
stepSource,
signalPort,
physicalPort,
);
await expect(physicalPort).not.toHaveAttribute(
"data-connection-state",
"compatible",
);
await page.mouse.up();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
expect(await distanceBetween(physicalPort, signalPort)).toBeGreaterThan(2);
await expect(physicalPort).toHaveAttribute("data-connected", "false");
await expect(signalPort).toHaveAttribute("data-connected", "false");
});
test("旋转后的接口可以与方向不同的兼容接口接触连接", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const horizontalOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 300,
y: 300,
});
const rotatedOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 650,
y: 500,
});
await rotatedOrifice.locator(".node-symbol").click();
await page
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
.click();
const horizontalPort = horizontalOrifice.locator(
'.port-handle[data-port-name="port_2"]',
);
const rotatedPort = rotatedOrifice.locator(
'.port-handle[data-port-name="port_1"]',
);
await dragNodePortNearTarget(
page,
rotatedOrifice,
rotatedPort,
horizontalPort,
);
await expect(horizontalPort).toHaveAttribute(
"data-connection-state",
"compatible",
);
await expect(rotatedPort).toHaveAttribute(
"data-connection-state",
"origin",
);
await page.mouse.up();
await expect
.poll(() => distanceBetween(horizontalPort, rotatedPort))
.toBeLessThan(2);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
await expect(horizontalPort).toHaveAttribute("data-connected", "true");
await expect(rotatedPort).toHaveAttribute("data-connected", "true");
});
test("旧工程保存的通用图标会按当前目录定义升级", async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
window.localStorage.setItem(
"system-simulation-flow:autosave",
JSON.stringify({
name: "legacy-symbol-project",
nodes: [
{
id: "amesim_pnl0001_1",
type: "simulationComponent",
position: { x: 200, y: 160 },
data: {
label: "amesim_pnl0001_1",
componentType: "amesim_pnl0001",
modelType: "amesim_pnl0001",
symbol: "pipe",
ports: [],
parameters: {},
parameterUnits: {},
rotation: 0,
mirrored: false,
},
},
],
edges: [],
simulation: {
t_start: 0,
t_stop: 1,
step: 0.1,
max_step: 0.05,
method: "BDF",
},
}),
);
});
await page.reload();
await page.getByRole("button", { name: "恢复", exact: true }).click();
const restoredNode = page.locator(
'.flow-canvas .react-flow__node[data-id="amesim_pnl0001_1"]',
);
await expect(restoredNode).toBeVisible();
await expect(
restoredNode.locator('[data-component-symbol="amesim_pnl0001"]'),
).toBeVisible();
await expect(restoredNode.locator('[data-component-symbol="pipe"]')).toHaveCount(0);
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.presentationLayoutVersion,
hasNodeSize:
"width" in (project.nodes?.[0] ?? {}) ||
"height" in (project.nodes?.[0] ?? {}),
}
: null;
}),
)
.toEqual({
position: { x: 208, y: 154 },
version: 1,
hasNodeSize: false,
});
await page.reload();
await page.getByRole("button", { name: "恢复", exact: true }).click();
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem(
"system-simulation-flow:autosave",
);
const project = raw ? JSON.parse(raw) : null;
return project?.nodes?.[0]?.position ?? null;
}),
)
.toEqual({ x: 208, y: 154 });
});
test("旧固定尺寸工程迁移后保持接触接口重合", async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
const makePort = (name: string, side: "left" | "right") => ({
name,
kind: "physical",
domain: "pneumatic",
nominalRole: "bidirectional",
positiveFlowDirection: "intoComponent",
side,
});
const makeNode = (id: string, x: number) => ({
id,
type: "simulationComponent",
position: { x, y: 160 },
data: {
label: id,
componentType: "amesim_pnor001",
modelType: "amesim_pnor001",
ports: [makePort("port_1", "left"), makePort("port_2", "right")],
parameters: {},
parameterUnits: {},
rotation: 0,
mirrored: false,
},
});
window.localStorage.setItem(
"system-simulation-flow:autosave",
JSON.stringify({
name: "legacy-contact-layout",
nodes: [makeNode("amesim_pnor001_1", 200), makeNode("amesim_pnor001_2", 305)],
edges: [
{
id: "legacy-contact-edge",
source: "amesim_pnor001_1",
target: "amesim_pnor001_2",
sourceHandle: "port_2",
targetHandle: "port_1",
},
],
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 firstPort = page.locator(
'.react-flow__node[data-id="amesim_pnor001_1"] .port-handle[data-port-name="port_2"]',
);
const secondPort = page.locator(
'.react-flow__node[data-id="amesim_pnor001_2"] .port-handle[data-port-name="port_1"]',
);
await expect(firstPort).toBeAttached();
await expect(secondPort).toBeAttached();
expect(await distanceBetween(firstPort, secondPort)).toBeLessThan(0.5);
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem(
"system-simulation-flow:autosave",
);
const project = raw ? JSON.parse(raw) : null;
return project
? {
positions: project.nodes.map(
(node: { position: { x: number; y: number } }) => ({
x: Number(node.position.x.toFixed(3)),
y: Number(node.position.y.toFixed(3)),
}),
),
version: project.presentationLayoutVersion,
}
: null;
}),
)
.toEqual({
positions: [
{ x: 231.7, y: 154 },
{ x: 289.3, y: 154 },
],
version: 1,
});
});
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 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).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/,
);
});