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("/"); 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("/"); 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/); await expect(horizontalEdge.locator("circle")).toHaveCount(0); 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 pathAfterSegmentDrags = await horizontalPath.getAttribute("d"); await expect(horizontalEdge.locator("circle")).toHaveCount(0); await page.keyboard.press("Control+z"); await expect(horizontalPath).toHaveAttribute( "d", pathBeforeVerticalDrag ?? "", ); await page.keyboard.press("Control+y"); await expect(horizontalPath).toHaveAttribute( "d", pathAfterSegmentDrags ?? "", ); const linePoints = [ ...(pathAfterSegmentDrags ?? "").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( '.flow-canvas .react-flow__edge[data-id="edge-1"] .react-flow__edge-path', ); const source = document.querySelector( '.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-port-name="port_b"]', ); const target = document.querySelector( '.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); }); for (const route of [ { label: "直线", targetY: 260, targetRotation: 0, pointCount: 2 }, { label: "L 型连接线", targetY: 480, targetRotation: 90, pointCount: 3 }, ]) { test(`${route.label}可直接拖动线条,首次折弯后仍连续且保持正交`, async ({ page, }) => { const project = { ...structuredClone(wideProject), nodes: [ projectNode("straight-source", 0, 260), { ...projectNode("straight-target", 700, route.targetY), data: { ...projectNode("straight-target", 700, route.targetY).data, rotation: route.targetRotation, }, }, ], 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"); await expect(edge.locator("circle")).toHaveCount(0); const routeHandle = edge.locator( '.manual-edge-segment-handle[data-segment-index="0"]', ); await expect(routeHandle).toHaveCount(1); await expect(routeHandle).toHaveCSS("pointer-events", "stroke"); const dragStart = await routeHandle.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 pathBefore = await edgePath.getAttribute("d"); expect([...(pathBefore ?? "").matchAll(/[ML]\s/g)]).toHaveLength(route.pointCount); 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("circle")).toHaveCount(0); 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 /); });