3101 lines
106 KiB
TypeScript
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/,
|
|
);
|
|
});
|