import { expect, test, type Locator, type Page } from "@playwright/test"; import { Position } from "@xyflow/react"; import { componentCatalog, expectAllNodesInsideCanvas, prepareApp, wideProject } from "./fixtures"; import { confirmOrthogonalCorner, extendOrthogonalRoute } from "../../src/edgeRouting"; const PROJECT_KEY = "system-simulation-flow:project:demo-system"; test("每次左键只确认一段并转向,空白预览不插入自动中点", () => { const source = { x: 0, y: 0 }; const pointer = { x: 120, y: 65 }; const fixed = confirmOrthogonalCorner([source], pointer, Position.Right); expect(fixed).toEqual([source, { x: 120, y: 0 }]); expect(extendOrthogonalRoute([source], pointer, Position.Right)).toEqual([ source, { x: 120, y: 0 }, pointer, ]); expect(extendOrthogonalRoute(fixed, pointer, Position.Right)).toEqual([ source, { x: 120, y: 0 }, pointer, ]); expect(confirmOrthogonalCorner(fixed, { x: 200, y: 95 }, Position.Right)).toEqual([ source, { x: 120, y: 0 }, { x: 120, y: 95 }, ]); expect(confirmOrthogonalCorner(fixed, { x: 300, y: 0 }, Position.Right)).toBe(fixed); expect(confirmOrthogonalCorner(fixed, { x: NaN, y: 65 }, Position.Right)).toBe(fixed); expect(confirmOrthogonalCorner([source], pointer, Position.Bottom)).toEqual([ source, { x: 0, y: 65 }, ]); }); test("各方向端口收尾只调整未确认末段,并保持正交", () => { for (const sourcePosition of Object.values(Position)) { for (const targetPosition of Object.values(Position)) { const fixed = confirmOrthogonalCorner([{ x: 0, y: 0 }], { x: 100, y: 80 }, sourcePosition); const target = { x: 270, y: 230 }; const route = extendOrthogonalRoute(fixed, target, sourcePosition, targetPosition); expect(route.slice(0, fixed.length)).toEqual(fixed); expect(route.at(-1)).toEqual(target); for (let i = 1; i < route.length; i++) { expect(route[i].x === route[i - 1].x || route[i].y === route[i - 1].y).toBe(true); expect(route[i]).not.toEqual(route[i - 1]); } const previous = route.at(-2)!; if (targetPosition === Position.Left || targetPosition === Position.Right) { expect(previous.y).toBe(target.y); } else { expect(previous.x).toBe(target.x); } } } }); async function loadRoutingProject(page: Page, rotation = 0, reverseSignal = false) { await prepareApp(page); if (reverseSignal) { const catalog = structuredClone(componentCatalog); const definition = catalog.libraries.flatMap((library) => library.components) .find((component) => component.type === "generic_sensor")!; definition.ports = definition.ports.map((port) => ({ ...port, kind: "signal", domain: "signal", nominalRole: port.side === "left" ? "input" : "output", })); await page.route("**/api/components/catalog", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(catalog), })); } const nodes = [ ["origin", 0, 0], ["target", 650, 0], ["obstacle", 280, 260], ["edge-source", 0, 450], ["edge-target", 650, 450], ].map(([id, x, y]) => ({ ...structuredClone(wideProject.nodes[0]), id: String(id), position: { x: Number(x), y: Number(y) }, data: { ...structuredClone(wideProject.nodes[0].data), label: String(id), rotation: id === "origin" ? rotation : 0 }, })); const project = { ...structuredClone(wideProject), nodes, edges: [{ ...wideProject.edges[0], id: "existing-edge", source: "edge-source", target: "edge-target", }] }; await page.addInitScript(({ key, value }) => { localStorage.setItem(key, JSON.stringify(value)); }, { key: PROJECT_KEY, value: project }); await page.goto("/"); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(5); await expectAllNodesInsideCanvas(page, ".flow-canvas", 5); const output = page.locator('.react-flow__node[data-id="origin"] .port-handle[data-port-name="port_b"]'); const input = page.locator('.react-flow__node[data-id="target"] .port-handle[data-port-name="port_a"]'); return { origin: reverseSignal ? input : output, target: reverseSignal ? output : input, draft: page.locator(".manual-connection-draft .react-flow__connection-path"), }; } async function center(locator: Locator) { const box = await locator.boundingBox(); expect(box).not.toBeNull(); return { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }; } async function pathPoints(path: Locator) { const values = (await path.getAttribute("d"))!.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)!.map(Number); return Array.from({ length: values.length / 2 }, (_, index) => ({ x: values[index * 2], y: values[index * 2 + 1], })); } for (const { rotation, reverseSignal } of [ { rotation: 0, reverseSignal: false }, { rotation: 90, reverseSignal: false }, { rotation: 0, reverseSignal: true }, ]) { test(`逐段定转折后已确认部分保持固定(${reverseSignal ? "信号输入端反向起画" : `旋转 ${rotation}°`})`, async ({ page }) => { const { origin, target, draft } = await loadRoutingProject(page, rotation, reverseSignal); const start = await center(origin); const waypoint = { x: Math.round(start.x + (reverseSignal ? -120 : 120)), y: Math.round(start.y + 65), }; await origin.click(); await page.mouse.move(waypoint.x, waypoint.y); await expect(draft).toBeVisible(); const beforeClick = await pathPoints(draft); // 初始为 L 形橡皮筋,一次点击只确认第一个转角,不固定末端。 expect(beforeClick).toHaveLength(3); const firstFixed = beforeClick.slice(0, -1); await page.mouse.click(waypoint.x, waypoint.y); await expect(draft).toHaveAttribute("data-waypoint-count", "1"); expect(await pathPoints(draft)).toEqual(beforeClick); for (const offset of [{x: 100, y: 30}, {x: -45, y: 110}, {x: 90, y: -55}]) { await page.mouse.move(waypoint.x + offset.x, waypoint.y + offset.y); await expect.poll(async () => (await pathPoints(draft)).slice(0, firstFixed.length)).toEqual(firstFixed); } const secondPoint = { x: waypoint.x + 90, y: waypoint.y - 55 }; const secondPreview = await pathPoints(draft); const secondFixed = secondPreview.slice(0, -1); await page.mouse.click(secondPoint.x, secondPoint.y); await expect(draft).toHaveAttribute("data-waypoint-count", "2"); expect(await pathPoints(draft)).toEqual(secondPreview); await page.mouse.move(secondPoint.x + 40, secondPoint.y + 110); await expect.poll(async () => (await pathPoints(draft)).slice(0, secondFixed.length)).toEqual(secondFixed); await target.hover(); const completedPreview = await pathPoints(draft); expect(completedPreview.slice(0, secondFixed.length)).toEqual(secondFixed); await target.click(); await expect(draft).toHaveCount(0); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2); await page.getByRole("button", { name: "保存工程", exact: true }).click(); const persisted = await page.evaluate((key) => { const project = JSON.parse(localStorage.getItem(key)!); return project.edges.find((edge: { id: string }) => edge.id !== "existing-edge").data.routePoints; }, PROJECT_KEY); expect(persisted).toEqual((reverseSignal ? completedPreview.reverse() : completedPreview).slice(1, -1)); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2); const finalizedPath = page.locator('.flow-canvas .react-flow__edge:not([data-id="existing-edge"]) .react-flow__edge-path'); const maximumDeviation = await finalizedPath.evaluate((path: SVGPathElement, fixed) => { const samples = Array.from({ length: Math.ceil(path.getTotalLength()) + 1 }, (_, index) => path.getPointAtLength(index), ); return Math.max(...fixed.map((point) => Math.min(...samples.map((sample) => Math.hypot(sample.x - point.x, sample.y - point.y))), )); }, secondFixed.slice(1)); expect(maximumDeviation).toBeLessThan(1); }); } test("右键逐段退回并恢复绘制方向,退到起点后再次右键取消", async ({ page }) => { const { origin, target, draft } = await loadRoutingProject(page); const start = await center(origin); await origin.click(); await page.mouse.click(start.x + 120, start.y + 65); await expect(draft).toHaveAttribute("data-waypoint-count", "1"); const firstFixed = (await pathPoints(draft)).slice(0, 2); await page.mouse.click(start.x + 200, start.y + 120); await expect(draft).toHaveAttribute("data-waypoint-count", "2"); await page.mouse.click(start.x + 250, start.y + 180, { button: "right" }); await expect(draft).toHaveAttribute("data-waypoint-count", "1"); const afterUndo = await pathPoints(draft); expect(afterUndo.slice(0, 2)).toEqual(firstFixed); expect(afterUndo[2].x).toBe(firstFixed[1].x); // 恢复为纵向绘制 await page.mouse.click(start.x + 250, start.y + 180, { button: "right" }); await expect(draft).toHaveAttribute("data-waypoint-count", "0"); const atStart = await pathPoints(draft); expect(atStart[1].y).toBe(atStart[0].y); // 恢复起始端口的横向 await page.mouse.click(start.x + 250, start.y + 180, { button: "right" }); await expect(draft).toHaveCount(0); await expect(origin).toHaveAttribute("data-connection-state", "idle"); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); // 取消的草稿不生成线路,下一次连接不受 React Flow 的点击状态干扰。 await origin.click(); await target.click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2); await expect(draft).toHaveCount(0); }); test("绘线时缩放、滚动条和中键平移不会确认额外转角或改变已确认路径", async ({ page }) => { const { origin, target, draft } = await loadRoutingProject(page); const start = await center(origin); await origin.click(); await page.mouse.click(start.x + 120, start.y + 65); const fixed = (await pathPoints(draft)).slice(0, 2); const viewport = page.locator(".flow-canvas .react-flow__viewport"); const beforeZoom = await viewport.getAttribute("style"); await page.keyboard.down("Control"); await page.mouse.wheel(0, 240); await page.keyboard.up("Control"); await expect(viewport).not.toHaveAttribute("style", beforeZoom!); expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed); const scrollbar = page.getByRole("scrollbar", { name: "水平滚动画布" }); const beforeScroll = await viewport.getAttribute("style"); await scrollbar.focus(); await page.keyboard.press("ArrowRight"); await expect(viewport).not.toHaveAttribute("style", beforeScroll!); await expect(draft).toHaveAttribute("data-waypoint-count", "1"); expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed); const beforePan = await viewport.getAttribute("style"); await page.mouse.move(start.x + 180, start.y + 180); await page.mouse.down({ button: "middle" }); await page.mouse.move(start.x + 220, start.y + 200, { steps: 4 }); await page.mouse.up({ button: "middle" }); await expect(viewport).not.toHaveAttribute("style", beforePan!); await expect(draft).toHaveAttribute("data-waypoint-count", "1"); expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed); await target.click(); await expect(draft).toHaveCount(0); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2); }); test("连接模式点击元件和已有连线只添加折点,取消后恢复选择", async ({ page }) => { const { origin, target, draft } = await loadRoutingProject(page); const obstacle = page.locator('.react-flow__node[data-id="obstacle"]'); const obstacleCenter = await center(obstacle.locator(".sim-node")); const edge = page.locator('.react-flow__edge[data-id="existing-edge"]'); const edgeCenter = await edge.locator(".react-flow__edge-path").evaluate((path: SVGPathElement) => { const p = path.getPointAtLength(path.getTotalLength() / 2).matrixTransform(path.getScreenCTM()!); return { x: p.x, y: p.y }; }); await page.mouse.click(edgeCenter.x, edgeCenter.y); await expect(edge).toHaveClass(/selected|editor-edge-selected/); await origin.click(); await expect(origin).toHaveAttribute("data-connection-state", "origin"); await page.mouse.click(obstacleCenter.x, obstacleCenter.y); await expect(draft).toHaveAttribute("data-waypoint-count", "1"); await expect(obstacle).not.toHaveClass(/selected/); await page.mouse.click(edgeCenter.x, edgeCenter.y); await expect(draft).toHaveAttribute("data-waypoint-count", "2"); await expect(edge).not.toHaveClass(/selected|editor-edge-selected/); await expect(page.locator(".manual-edge-segment-handle, .manual-edge-corner-handle, .manual-edge-route-handle")).toHaveCount(0); await expect(origin).toHaveAttribute("data-connection-state", "origin"); await expect(target).toHaveAttribute("data-connection-state", "compatible"); const beforeDrag = await obstacle.getAttribute("style"); await page.mouse.move(obstacleCenter.x, obstacleCenter.y); await page.mouse.down(); await page.mouse.move(obstacleCenter.x + 35, obstacleCenter.y + 25, { steps: 3 }); await page.mouse.up(); expect(await obstacle.getAttribute("style")).toBe(beforeDrag); await expect(page.locator(".react-flow__selection")).toHaveCount(0); await page.keyboard.press("Escape"); await expect(draft).toHaveCount(0); await page.mouse.click(obstacleCenter.x, obstacleCenter.y); await expect(obstacle).toHaveClass(/selected/); await page.mouse.click(edgeCenter.x, edgeCenter.y); await expect(edge).toHaveClass(/selected|editor-edge-selected/); });