796 lines
27 KiB
TypeScript
796 lines
27 KiB
TypeScript
import { expect, test, type Locator, type Page } from "@playwright/test";
|
|
|
|
import { prepareApp, wideProject } from "./fixtures";
|
|
|
|
const PROJECT_KEY = "system-simulation-flow:project:demo-system";
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await prepareApp(page);
|
|
});
|
|
|
|
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 dragGenericToCanvas(
|
|
page: Page,
|
|
targetPosition: { x: number; y: number },
|
|
) {
|
|
const currentCount = await page
|
|
.locator('.flow-canvas .react-flow__node[data-id^="generic_sensor_"]')
|
|
.count();
|
|
await page
|
|
.getByRole("button", { name: /通用测试元件/ })
|
|
.dragTo(page.locator(".flow-canvas .react-flow__pane"), { targetPosition });
|
|
const node = page.locator(
|
|
`.flow-canvas .react-flow__node[data-id="generic_sensor_${currentCount + 1}"]`,
|
|
);
|
|
await expect(node).toBeVisible();
|
|
return node;
|
|
}
|
|
|
|
async function moveNodePortNearTarget(
|
|
page: Page,
|
|
movingNode: Locator,
|
|
movingPort: Locator,
|
|
targetPort: Locator,
|
|
remainingGap = 8,
|
|
) {
|
|
const [dragStart, movingPortCenter, targetPortCenter] = await Promise.all([
|
|
locatorCenter(movingNode.locator(".sim-node")),
|
|
locatorCenter(movingPort),
|
|
locatorCenter(targetPort),
|
|
]);
|
|
const deltaX = targetPortCenter.x - movingPortCenter.x;
|
|
const deltaY = targetPortCenter.y - movingPortCenter.y;
|
|
const distance = Math.hypot(deltaX, deltaY);
|
|
const ratio = (distance - remainingGap) / distance;
|
|
let pointerX = dragStart.x + deltaX * ratio;
|
|
let pointerY = dragStart.y + deltaY * ratio;
|
|
|
|
await page.mouse.move(dragStart.x, dragStart.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(pointerX, pointerY, { steps: 12 });
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
const [movingCenter, targetCenter] = await Promise.all([
|
|
locatorCenter(movingPort),
|
|
locatorCenter(targetPort),
|
|
]);
|
|
const correctionX = targetCenter.x - movingCenter.x;
|
|
const correctionY = targetCenter.y - movingCenter.y;
|
|
const correctionDistance = Math.hypot(correctionX, correctionY);
|
|
if (correctionDistance <= remainingGap + 1) {
|
|
break;
|
|
}
|
|
const correctionRatio =
|
|
(correctionDistance - remainingGap) / correctionDistance;
|
|
pointerX += correctionX * correctionRatio;
|
|
pointerY += correctionY * correctionRatio;
|
|
await page.mouse.move(pointerX, pointerY, { steps: 4 });
|
|
}
|
|
return { pointerX, pointerY };
|
|
}
|
|
|
|
async function movePointerUntilPortsMeet(
|
|
page: Page,
|
|
pointer: { x: number; y: number },
|
|
movingPort: Locator,
|
|
targetPort: Locator,
|
|
) {
|
|
const nextPointer = { ...pointer };
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
const [movingCenter, targetCenter] = await Promise.all([
|
|
locatorCenter(movingPort),
|
|
locatorCenter(targetPort),
|
|
]);
|
|
const correction = {
|
|
x: targetCenter.x - movingCenter.x,
|
|
y: targetCenter.y - movingCenter.y,
|
|
};
|
|
if (Math.hypot(correction.x, correction.y) < 2) {
|
|
break;
|
|
}
|
|
nextPointer.x += correction.x;
|
|
nextPointer.y += correction.y;
|
|
await page.mouse.move(nextPointer.x, nextPointer.y, { steps: 6 });
|
|
}
|
|
return nextPointer;
|
|
}
|
|
|
|
function projectNode(id: string, x: number, y: number) {
|
|
const template = structuredClone(wideProject.nodes[0]);
|
|
return {
|
|
...template,
|
|
id,
|
|
position: { x, y },
|
|
data: {
|
|
...template.data,
|
|
label: id,
|
|
},
|
|
};
|
|
}
|
|
|
|
test("多选块拖动会即时断开边界接触,并由块内空闲端口整体吸附", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
|
|
|
const fixedNode = await dragGenericToCanvas(page, { x: 150, y: 180 });
|
|
const firstMovingNode = await dragGenericToCanvas(page, { x: 410, y: 180 });
|
|
const secondMovingNode = await dragGenericToCanvas(page, { x: 410, y: 430 });
|
|
const snapTargetNode = await dragGenericToCanvas(page, { x: 720, y: 430 });
|
|
const fixedPort = fixedNode.locator('.port-handle[data-port-name="port_b"]');
|
|
const boundaryPort = firstMovingNode.locator(
|
|
'.port-handle[data-port-name="port_a"]',
|
|
);
|
|
const blockFreePort = secondMovingNode.locator(
|
|
'.port-handle[data-port-name="port_b"]',
|
|
);
|
|
const targetFreePort = snapTargetNode.locator(
|
|
'.port-handle[data-port-name="port_a"]',
|
|
);
|
|
|
|
await moveNodePortNearTarget(
|
|
page,
|
|
firstMovingNode,
|
|
boundaryPort,
|
|
fixedPort,
|
|
);
|
|
await page.mouse.up();
|
|
await expect.poll(() => distanceBetween(boundaryPort, fixedPort)).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 firstMovingNode.locator(".sim-node").click();
|
|
await secondMovingNode.locator(".sim-node").click({ modifiers: ["Control"] });
|
|
await expect(firstMovingNode).toHaveClass(/selected/);
|
|
await expect(secondMovingNode).toHaveClass(/selected/);
|
|
const [firstBefore, secondBefore] = await Promise.all([
|
|
firstMovingNode.boundingBox(),
|
|
secondMovingNode.boundingBox(),
|
|
]);
|
|
expect(firstBefore).not.toBeNull();
|
|
expect(secondBefore).not.toBeNull();
|
|
|
|
await moveNodePortNearTarget(
|
|
page,
|
|
firstMovingNode,
|
|
blockFreePort,
|
|
targetFreePort,
|
|
);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
|
await expect(blockFreePort).toHaveAttribute("data-connection-state", "origin");
|
|
await expect(targetFreePort).toHaveAttribute(
|
|
"data-connection-state",
|
|
"compatible",
|
|
);
|
|
await page.mouse.up();
|
|
|
|
await expect.poll(() => distanceBetween(blockFreePort, targetFreePort)).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(boundaryPort).toHaveAttribute("data-connected", "false");
|
|
await expect(fixedPort).toHaveAttribute("data-connected", "false");
|
|
await expect(blockFreePort).toHaveAttribute("data-connected", "true");
|
|
await expect(targetFreePort).toHaveAttribute("data-connected", "true");
|
|
|
|
const [firstAfter, secondAfter] = await Promise.all([
|
|
firstMovingNode.boundingBox(),
|
|
secondMovingNode.boundingBox(),
|
|
]);
|
|
expect(firstAfter).not.toBeNull();
|
|
expect(secondAfter).not.toBeNull();
|
|
expect(firstAfter!.x - firstBefore!.x).toBeCloseTo(
|
|
secondAfter!.x - secondBefore!.x,
|
|
1,
|
|
);
|
|
expect(firstAfter!.y - firstBefore!.y).toBeCloseTo(
|
|
secondAfter!.y - secondBefore!.y,
|
|
1,
|
|
);
|
|
});
|
|
|
|
test("复制块保留内部连接,同时未连接端口仍参与待放置吸附", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
|
|
|
const first = await dragGenericToCanvas(page, { x: 170, y: 180 });
|
|
const second = await dragGenericToCanvas(page, { x: 410, y: 370 });
|
|
const target = await dragGenericToCanvas(page, { x: 720, y: 370 });
|
|
await first.locator('.port-handle[data-port-name="port_b"]').click();
|
|
await second.locator('.port-handle[data-port-name="port_a"]').click();
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
|
|
|
await first.locator(".sim-node").click();
|
|
await second.locator(".sim-node").click({ modifiers: ["Control"] });
|
|
await expect(first).toHaveClass(/selected/);
|
|
await expect(second).toHaveClass(/selected/);
|
|
await page.keyboard.press("Control+c");
|
|
|
|
const paneBounds = await page.locator(".flow-canvas .react-flow__pane").boundingBox();
|
|
expect(paneBounds).not.toBeNull();
|
|
let pointer = {
|
|
x: paneBounds!.x + paneBounds!.width * 0.5,
|
|
y: paneBounds!.y + paneBounds!.height * 0.72,
|
|
};
|
|
await page.mouse.move(pointer.x, pointer.y);
|
|
await page.keyboard.press("Control+v");
|
|
|
|
const firstPreview = page.locator(
|
|
'.flow-canvas .react-flow__node[data-id="generic_sensor_4"]',
|
|
);
|
|
const secondPreview = page.locator(
|
|
'.flow-canvas .react-flow__node[data-id="generic_sensor_5"]',
|
|
);
|
|
await expect(firstPreview).toHaveClass(/pending-paste-node/);
|
|
await expect(secondPreview).toHaveClass(/pending-paste-node/);
|
|
await expect(page.locator(".flow-canvas .pending-paste-edge")).toHaveCount(1);
|
|
await expect(
|
|
firstPreview.locator('.port-handle[data-port-name="port_b"]'),
|
|
).toHaveAttribute("data-connection-state", "connected");
|
|
await expect(
|
|
secondPreview.locator('.port-handle[data-port-name="port_a"]'),
|
|
).toHaveAttribute("data-connection-state", "connected");
|
|
|
|
const copiedFreePort = secondPreview.locator(
|
|
'.port-handle[data-port-name="port_b"]',
|
|
);
|
|
const targetPort = target.locator('.port-handle[data-port-name="port_a"]');
|
|
pointer = await movePointerUntilPortsMeet(
|
|
page,
|
|
pointer,
|
|
copiedFreePort,
|
|
targetPort,
|
|
);
|
|
await expect.poll(() => distanceBetween(copiedFreePort, targetPort)).toBeLessThan(2);
|
|
await expect(copiedFreePort).toHaveAttribute("data-connection-state", "origin");
|
|
await expect(targetPort).toHaveAttribute("data-connection-state", "compatible");
|
|
|
|
await page.mouse.click(pointer.x, pointer.y);
|
|
await expect(firstPreview).not.toHaveClass(/pending-paste-node/);
|
|
await expect(secondPreview).not.toHaveClass(/pending-paste-node/);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(3);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(2);
|
|
await expect(copiedFreePort).toHaveAttribute("data-connected", "true");
|
|
await expect(targetPort).toHaveAttribute("data-connected", "true");
|
|
});
|
|
|
|
test("正交连接线可拖动内部线段,且无连接交叉点显示跨线桥", async ({
|
|
page,
|
|
}) => {
|
|
const project = {
|
|
...structuredClone(wideProject),
|
|
nodes: [
|
|
projectNode("left", 0, 260),
|
|
projectNode("right", 700, 260),
|
|
projectNode("top", 350, 0),
|
|
projectNode("bottom", 350, 520),
|
|
],
|
|
edges: [
|
|
{
|
|
id: "edge-horizontal",
|
|
source: "left",
|
|
target: "right",
|
|
sourceHandle: "port_b",
|
|
targetHandle: "port_a",
|
|
data: {
|
|
isContactEdge: false,
|
|
routePoints: [
|
|
{ x: 250, y: 360 },
|
|
{ x: 650, y: 360 },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
id: "edge-vertical",
|
|
source: "top",
|
|
target: "bottom",
|
|
sourceHandle: "port_b",
|
|
targetHandle: "port_a",
|
|
data: {
|
|
isContactEdge: false,
|
|
routePoints: [
|
|
{ x: 450, y: 150 },
|
|
{ x: 450, y: 550 },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
await page.addInitScript((storedProject) => {
|
|
window.localStorage.setItem(
|
|
"system-simulation-flow:project:demo-system",
|
|
JSON.stringify(storedProject),
|
|
);
|
|
}, project);
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
|
|
|
const horizontalEdge = page.locator(
|
|
'.flow-canvas .react-flow__edge[data-id="edge-horizontal"]',
|
|
);
|
|
const verticalEdge = page.locator(
|
|
'.flow-canvas .react-flow__edge[data-id="edge-vertical"]',
|
|
);
|
|
await expect(horizontalEdge).toBeVisible();
|
|
await expect(verticalEdge).toBeVisible();
|
|
const horizontalPath = horizontalEdge.locator(".react-flow__edge-path");
|
|
await expect(horizontalPath).toHaveAttribute("d", / Q /);
|
|
await expect(verticalEdge.locator(".react-flow__edge-path")).not.toHaveAttribute(
|
|
"d",
|
|
/ Q /,
|
|
);
|
|
|
|
await horizontalEdge
|
|
.locator(".react-flow__edge-interaction")
|
|
.dispatchEvent("click");
|
|
await expect(horizontalEdge).toHaveClass(/selected|editor-edge-selected/);
|
|
const horizontalSegment = horizontalEdge
|
|
.locator(".manual-edge-segment-handle.horizontal")
|
|
.first();
|
|
const verticalSegment = horizontalEdge
|
|
.locator(".manual-edge-segment-handle.vertical")
|
|
.first();
|
|
await expect(horizontalSegment).toHaveCount(1);
|
|
await expect(verticalSegment).toHaveCount(1);
|
|
await expect(horizontalSegment).toHaveCSS("pointer-events", "stroke");
|
|
await expect(verticalSegment).toHaveCSS("pointer-events", "stroke");
|
|
|
|
const horizontalDragPoint = await horizontalSegment.evaluate(
|
|
(line: SVGLineElement) => {
|
|
const matrix = line.getScreenCTM();
|
|
if (!matrix) {
|
|
throw new Error("无法获取水平连接线段坐标");
|
|
}
|
|
const point = new DOMPoint(
|
|
line.x1.baseVal.value * 0.75 + line.x2.baseVal.value * 0.25,
|
|
line.y1.baseVal.value * 0.75 + line.y2.baseVal.value * 0.25,
|
|
).matrixTransform(matrix);
|
|
return { x: point.x, y: point.y };
|
|
},
|
|
);
|
|
const pathBeforeHorizontalDrag = await horizontalPath.getAttribute("d");
|
|
await page.mouse.move(horizontalDragPoint.x, horizontalDragPoint.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(horizontalDragPoint.x, horizontalDragPoint.y + 36, {
|
|
steps: 6,
|
|
});
|
|
await page.mouse.up();
|
|
await expect(horizontalPath).not.toHaveAttribute(
|
|
"d",
|
|
pathBeforeHorizontalDrag ?? "",
|
|
);
|
|
await expect(horizontalPath).toHaveAttribute("d", / Q /);
|
|
|
|
const verticalDragPoint = await verticalSegment.evaluate(
|
|
(line: SVGLineElement) => {
|
|
const matrix = line.getScreenCTM();
|
|
if (!matrix) {
|
|
throw new Error("无法获取竖直连接线段坐标");
|
|
}
|
|
const point = new DOMPoint(
|
|
(line.x1.baseVal.value + line.x2.baseVal.value) / 2,
|
|
(line.y1.baseVal.value + line.y2.baseVal.value) / 2,
|
|
).matrixTransform(matrix);
|
|
return { x: point.x, y: point.y };
|
|
},
|
|
);
|
|
const pathBeforeVerticalDrag = await horizontalPath.getAttribute("d");
|
|
await page.mouse.move(verticalDragPoint.x, verticalDragPoint.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(verticalDragPoint.x + 28, verticalDragPoint.y, {
|
|
steps: 6,
|
|
});
|
|
await page.mouse.up();
|
|
await expect(horizontalPath).not.toHaveAttribute(
|
|
"d",
|
|
pathBeforeVerticalDrag ?? "",
|
|
);
|
|
|
|
const cornerHandle = horizontalEdge.locator(
|
|
'.manual-edge-corner-handle[data-corner-index="2"]',
|
|
);
|
|
await expect(cornerHandle).toHaveCount(1);
|
|
const cornerBefore = await cornerHandle.evaluate(
|
|
(circle: SVGCircleElement) => ({
|
|
flowX: circle.cx.baseVal.value,
|
|
flowY: circle.cy.baseVal.value,
|
|
screen: (() => {
|
|
const matrix = circle.getScreenCTM();
|
|
if (!matrix) {
|
|
throw new Error("无法获取连接线拐点坐标");
|
|
}
|
|
const point = new DOMPoint(
|
|
circle.cx.baseVal.value,
|
|
circle.cy.baseVal.value,
|
|
).matrixTransform(matrix);
|
|
return { x: point.x, y: point.y };
|
|
})(),
|
|
}),
|
|
);
|
|
const pathBeforeCornerDrag = await horizontalPath.getAttribute("d");
|
|
await page.mouse.move(cornerBefore.screen.x, cornerBefore.screen.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(
|
|
cornerBefore.screen.x + 32,
|
|
cornerBefore.screen.y + 24,
|
|
{ steps: 6 },
|
|
);
|
|
await page.mouse.up();
|
|
await expect(horizontalPath).not.toHaveAttribute(
|
|
"d",
|
|
pathBeforeCornerDrag ?? "",
|
|
);
|
|
const cornerAfter = await cornerHandle.evaluate(
|
|
(circle: SVGCircleElement) => ({
|
|
flowX: circle.cx.baseVal.value,
|
|
flowY: circle.cy.baseVal.value,
|
|
}),
|
|
);
|
|
expect(Math.abs(cornerAfter.flowX - cornerBefore.flowX)).toBeGreaterThan(1);
|
|
expect(Math.abs(cornerAfter.flowY - cornerBefore.flowY)).toBeGreaterThan(1);
|
|
|
|
await page.keyboard.press("Control+z");
|
|
await expect(horizontalPath).toHaveAttribute(
|
|
"d",
|
|
pathBeforeCornerDrag ?? "",
|
|
);
|
|
await horizontalEdge
|
|
.locator(".react-flow__edge-interaction")
|
|
.dispatchEvent("click");
|
|
await expect(horizontalEdge).toHaveClass(/selected|editor-edge-selected/);
|
|
|
|
const boundaryCorner = horizontalEdge.locator(
|
|
'.manual-edge-corner-handle[data-corner-index="1"]',
|
|
);
|
|
await expect(boundaryCorner).toHaveCount(1);
|
|
const boundaryStart = await boundaryCorner.evaluate(
|
|
(circle: SVGCircleElement) => {
|
|
const matrix = circle.getScreenCTM();
|
|
if (!matrix) {
|
|
throw new Error("无法获取端点相邻拐点坐标");
|
|
}
|
|
const point = new DOMPoint(
|
|
circle.cx.baseVal.value,
|
|
circle.cy.baseVal.value,
|
|
).matrixTransform(matrix);
|
|
return { x: point.x, y: point.y };
|
|
},
|
|
);
|
|
const boundaryPathLocator = horizontalEdge.locator(".react-flow__edge-path");
|
|
const boundaryPathBefore = await boundaryPathLocator.getAttribute("d");
|
|
const boundaryTarget = {
|
|
x: boundaryStart.x + 38,
|
|
y: boundaryStart.y + 30,
|
|
};
|
|
await page.mouse.move(boundaryStart.x, boundaryStart.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(
|
|
(boundaryStart.x + boundaryTarget.x) / 2,
|
|
(boundaryStart.y + boundaryTarget.y) / 2,
|
|
{ steps: 4 },
|
|
);
|
|
await page.mouse.move(boundaryTarget.x, boundaryTarget.y, { steps: 4 });
|
|
await page.mouse.up();
|
|
await expect(boundaryPathLocator).not.toHaveAttribute(
|
|
"d",
|
|
boundaryPathBefore ?? "",
|
|
);
|
|
await expect(boundaryPathLocator).toHaveAttribute("d", / Q /);
|
|
|
|
const cornerScreenPoints = await horizontalEdge
|
|
.locator(".manual-edge-corner-handle")
|
|
.evaluateAll((circles: SVGCircleElement[]) =>
|
|
circles.map((circle) => {
|
|
const matrix = circle.getScreenCTM();
|
|
if (!matrix) {
|
|
throw new Error("无法获取调整后的拐点坐标");
|
|
}
|
|
const point = new DOMPoint(
|
|
circle.cx.baseVal.value,
|
|
circle.cy.baseVal.value,
|
|
).matrixTransform(matrix);
|
|
return { x: point.x, y: point.y };
|
|
}),
|
|
);
|
|
const closestCornerDistance = Math.min(
|
|
...cornerScreenPoints.map((point) =>
|
|
Math.hypot(point.x - boundaryTarget.x, point.y - boundaryTarget.y),
|
|
),
|
|
);
|
|
expect(
|
|
closestCornerDistance,
|
|
JSON.stringify({ boundaryStart, boundaryTarget, cornerScreenPoints }),
|
|
).toBeLessThan(3);
|
|
|
|
const boundaryPathAfter = await boundaryPathLocator.getAttribute("d");
|
|
const linePoints = [
|
|
...(boundaryPathAfter ?? "").matchAll(
|
|
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
|
|
),
|
|
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
|
|
expect(linePoints.length).toBeGreaterThan(2);
|
|
linePoints.slice(1).forEach((point, index) => {
|
|
const previous = linePoints[index];
|
|
expect(
|
|
Math.abs(point.x - previous.x) < 0.01 ||
|
|
Math.abs(point.y - previous.y) < 0.01,
|
|
).toBe(true);
|
|
});
|
|
|
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
|
const savedRoute = await page.evaluate((projectKey) => {
|
|
const raw = window.localStorage.getItem(projectKey);
|
|
const saved = raw ? JSON.parse(raw) : null;
|
|
return saved?.edges?.find(
|
|
(edge: { id?: string }) => edge.id === "edge-horizontal",
|
|
)?.data?.routePoints ?? null;
|
|
}, PROJECT_KEY);
|
|
expect(savedRoute).not.toEqual(project.edges[0].data.routePoints);
|
|
expect(savedRoute?.length).toBeGreaterThan(1);
|
|
});
|
|
|
|
test("缩小画布后连接线端点仍与端口图标边缘对齐", async ({ page }) => {
|
|
await page.addInitScript((storedProject) => {
|
|
window.localStorage.setItem(
|
|
"system-simulation-flow:project:demo-system",
|
|
JSON.stringify(storedProject),
|
|
);
|
|
}, wideProject);
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
|
|
|
const viewport = page.locator(".flow-canvas .react-flow__viewport");
|
|
const readViewportZoom = () =>
|
|
viewport.evaluate((element) => {
|
|
const values =
|
|
getComputedStyle(element)
|
|
.transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)
|
|
?.map(Number) ?? [];
|
|
return values[0] ?? 1;
|
|
});
|
|
const initialZoom = await readViewportZoom();
|
|
const pane = page.locator(".flow-canvas .react-flow__pane");
|
|
const paneBounds = await pane.boundingBox();
|
|
expect(paneBounds).not.toBeNull();
|
|
await page.mouse.move(
|
|
paneBounds!.x + paneBounds!.width / 2,
|
|
paneBounds!.y + paneBounds!.height / 2,
|
|
);
|
|
await page.keyboard.down("Control");
|
|
await page.mouse.wheel(0, 720);
|
|
await page.keyboard.up("Control");
|
|
await expect
|
|
.poll(async () => (await readViewportZoom()) / initialZoom)
|
|
.toBeLessThan(0.7);
|
|
const zoom = await readViewportZoom();
|
|
|
|
const alignment = await page.evaluate(() => {
|
|
const path = document.querySelector<SVGPathElement>(
|
|
'.flow-canvas .react-flow__edge[data-id="edge-1"] .react-flow__edge-path',
|
|
);
|
|
const source = document.querySelector<HTMLElement>(
|
|
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-port-name="port_b"]',
|
|
);
|
|
const target = document.querySelector<HTMLElement>(
|
|
'.flow-canvas .react-flow__node[data-id="generic_sensor_2"] .port-handle[data-port-name="port_a"]',
|
|
);
|
|
const matrix = path?.getScreenCTM();
|
|
if (!path || !source || !target || !matrix) {
|
|
return null;
|
|
}
|
|
const start = path.getPointAtLength(0).matrixTransform(matrix);
|
|
const end = path.getPointAtLength(path.getTotalLength()).matrixTransform(matrix);
|
|
const sourceBounds = source.getBoundingClientRect();
|
|
const targetBounds = target.getBoundingClientRect();
|
|
return {
|
|
sourceGapX: Math.abs(start.x - sourceBounds.right),
|
|
sourceGapY: Math.abs(
|
|
start.y - (sourceBounds.top + sourceBounds.height / 2),
|
|
),
|
|
sourceWidth: sourceBounds.width,
|
|
targetGapX: Math.abs(end.x - targetBounds.left),
|
|
targetGapY: Math.abs(
|
|
end.y - (targetBounds.top + targetBounds.height / 2),
|
|
),
|
|
targetWidth: targetBounds.width,
|
|
};
|
|
});
|
|
|
|
expect(alignment).not.toBeNull();
|
|
expect(Math.abs(alignment!.sourceWidth - 6 * zoom)).toBeLessThan(0.1);
|
|
expect(Math.abs(alignment!.targetWidth - 6 * zoom)).toBeLessThan(0.1);
|
|
expect(alignment!.sourceGapX).toBeLessThan(0.75);
|
|
expect(alignment!.sourceGapY).toBeLessThan(0.75);
|
|
expect(alignment!.targetGapX).toBeLessThan(0.75);
|
|
expect(alignment!.targetGapY).toBeLessThan(0.75);
|
|
});
|
|
|
|
test("直线首次折弯后即使原手柄卸载,拖动仍连续且保持正交", async ({
|
|
page,
|
|
}) => {
|
|
const project = {
|
|
...structuredClone(wideProject),
|
|
nodes: [
|
|
projectNode("straight-source", 0, 260),
|
|
projectNode("straight-target", 700, 260),
|
|
],
|
|
edges: [
|
|
{
|
|
id: "straight-edge",
|
|
source: "straight-source",
|
|
target: "straight-target",
|
|
sourceHandle: "port_b",
|
|
targetHandle: "port_a",
|
|
data: { isContactEdge: false },
|
|
},
|
|
],
|
|
};
|
|
await page.addInitScript((storedProject) => {
|
|
window.localStorage.setItem(
|
|
"system-simulation-flow:project:demo-system",
|
|
JSON.stringify(storedProject),
|
|
);
|
|
}, project);
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
|
|
|
const edge = page.locator(
|
|
'.flow-canvas .react-flow__edge[data-id="straight-edge"]',
|
|
);
|
|
const edgePath = edge.locator(".react-flow__edge-path");
|
|
await edge.locator(".react-flow__edge-interaction").dispatchEvent("click");
|
|
const routeHandle = edge.locator(".manual-edge-route-handle");
|
|
await expect(routeHandle).toHaveCount(1);
|
|
const dragStart = await routeHandle.evaluate((circle: SVGCircleElement) => {
|
|
const matrix = circle.getScreenCTM();
|
|
if (!matrix) {
|
|
throw new Error("无法获取直线路由手柄坐标");
|
|
}
|
|
const point = new DOMPoint(
|
|
circle.cx.baseVal.value,
|
|
circle.cy.baseVal.value,
|
|
).matrixTransform(matrix);
|
|
return { x: point.x, y: point.y };
|
|
});
|
|
const pathBefore = await edgePath.getAttribute("d");
|
|
await page.mouse.move(dragStart.x, dragStart.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(dragStart.x + 18, dragStart.y + 21, { steps: 4 });
|
|
|
|
await expect(routeHandle).toHaveCount(0);
|
|
const verticalSegment = edge
|
|
.locator(".manual-edge-segment-handle.vertical")
|
|
.first();
|
|
await expect(verticalSegment).toHaveCount(1);
|
|
const segmentXAtHalfMove = await verticalSegment.evaluate(
|
|
(line: SVGLineElement) => {
|
|
const matrix = line.getScreenCTM();
|
|
if (!matrix) {
|
|
throw new Error("无法获取首次折弯后的竖直线段坐标");
|
|
}
|
|
return new DOMPoint(
|
|
line.x1.baseVal.value,
|
|
line.y1.baseVal.value,
|
|
).matrixTransform(matrix).x;
|
|
},
|
|
);
|
|
|
|
await page.mouse.move(dragStart.x + 36, dragStart.y + 42, { steps: 4 });
|
|
const segmentXAtFullMove = await verticalSegment.evaluate(
|
|
(line: SVGLineElement) => {
|
|
const matrix = line.getScreenCTM();
|
|
if (!matrix) {
|
|
throw new Error("无法获取连续拖动后的竖直线段坐标");
|
|
}
|
|
return new DOMPoint(
|
|
line.x1.baseVal.value,
|
|
line.y1.baseVal.value,
|
|
).matrixTransform(matrix).x;
|
|
},
|
|
);
|
|
await page.mouse.up();
|
|
expect(Math.abs(segmentXAtFullMove - segmentXAtHalfMove)).toBeGreaterThan(10);
|
|
await expect(edgePath).not.toHaveAttribute("d", pathBefore ?? "");
|
|
await expect(
|
|
edge.locator(".manual-edge-segment-handle.horizontal").first(),
|
|
).toHaveCount(1);
|
|
|
|
const routeAfter = await edgePath.getAttribute("d");
|
|
const routePoints = [
|
|
...(routeAfter ?? "").matchAll(
|
|
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
|
|
),
|
|
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
|
|
routePoints.slice(1).forEach((point, index) => {
|
|
const previous = routePoints[index];
|
|
expect(
|
|
Math.abs(point.x - previous.x) < 0.01 ||
|
|
Math.abs(point.y - previous.y) < 0.01,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
test("同一组件不同端口发出的线路在远端交叉时仍显示跨线桥", async ({
|
|
page,
|
|
}) => {
|
|
const project = {
|
|
...structuredClone(wideProject),
|
|
nodes: [
|
|
projectNode("shared-source", 0, 260),
|
|
projectNode("right-target", 700, 260),
|
|
projectNode("bottom-target", 350, 520),
|
|
],
|
|
edges: [
|
|
{
|
|
id: "shared-horizontal",
|
|
source: "shared-source",
|
|
target: "right-target",
|
|
sourceHandle: "port_b",
|
|
targetHandle: "port_a",
|
|
data: {
|
|
isContactEdge: false,
|
|
routePoints: [
|
|
{ x: 250, y: 360 },
|
|
{ x: 650, y: 360 },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
id: "shared-vertical",
|
|
source: "shared-source",
|
|
target: "bottom-target",
|
|
sourceHandle: "port_a",
|
|
targetHandle: "port_a",
|
|
data: {
|
|
isContactEdge: false,
|
|
routePoints: [
|
|
{ x: 250, y: 360 },
|
|
{ x: 250, y: 180 },
|
|
{ x: 450, y: 180 },
|
|
{ x: 450, y: 540 },
|
|
{ x: 350, y: 540 },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
await page.addInitScript((storedProject) => {
|
|
window.localStorage.setItem(
|
|
"system-simulation-flow:project:demo-system",
|
|
JSON.stringify(storedProject),
|
|
);
|
|
}, project);
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
|
|
|
const horizontalPath = page.locator(
|
|
'.flow-canvas .react-flow__edge[data-id="shared-horizontal"] .react-flow__edge-path',
|
|
);
|
|
const verticalPath = page.locator(
|
|
'.flow-canvas .react-flow__edge[data-id="shared-vertical"] .react-flow__edge-path',
|
|
);
|
|
await expect(horizontalPath).toBeVisible();
|
|
await expect(horizontalPath).toHaveAttribute("d", / Q /);
|
|
await expect(verticalPath).not.toHaveAttribute("d", / Q /);
|
|
});
|