import { expect, test, type Locator, type Page } from "@playwright/test"; import { buildAlignmentIndex, findAlignment, type AlignmentAnchor } from "../../src/alignmentGuides"; import { componentCatalog, expectAllNodesInsideCanvas, prepareApp, wideProject } from "./fixtures"; const KEY = "system-simulation-flow:autosave"; const anchor = (nodeId: string, kind: AlignmentAnchor["kind"], x: number, y: number): AlignmentAnchor => ({ id: `${nodeId}:${kind}`, nodeId, kind, x, y }); test("中心、定位点和端口按同类参考点对齐,整块采用同一个平移量", () => { for (const kind of ["center", "origin", "port"] as const) { const index = buildAlignmentIndex([anchor("ref-x", kind, 100, 200), anchor("ref-y", kind, 300, 40)]); const result = findAlignment([anchor("a", kind, 95, 10), anchor("b", kind, 145, 37)], index, 1)!; expect(result.positionDelta).toEqual({ x: 5, y: 3 }); expect(result.guides.map((g) => g.kind)).toEqual([kind, kind]); expect(result.guides.every((g) => g.from[g.axis] === g.to[g.axis])).toBe(true); } expect(findAlignment([anchor("a", "origin", 95, 10)], buildAlignmentIndex([anchor("ref", "center", 100, 200)]), 1)).toBeNull(); }); test("对齐容差保持约 8 屏幕像素,远处元件与离开范围的元件不吸附", () => { const index = buildAlignmentIndex([anchor("ref", "center", 100, 100)]); expect(findAlignment([anchor("a", "center", 96.5, 0)], index, 2)?.positionDelta.x).toBe(3.5); expect(findAlignment([anchor("a", "center", 95, 0)], index, 2)).toBeNull(); expect(findAlignment([anchor("a", "center", 96, 5000)], index, 1)).toBeNull(); expect(findAlignment([anchor("a", "center", 70, 0)], index, 0.1)).toBeNull(); }); test("大量元件共用同一坐标时,索引仍选取另一方向最近的参考点", () => { const index = buildAlignmentIndex(Array.from({ length: 20000 }, (_, i) => anchor(`ref-${i}`, "port", 100, i * 20))); const result = findAlignment([anchor("a", "port", 104, 200005)], index, 1)!; expect(result.positionDelta).toEqual({ x: -4, y: -5 }); expect(result.guides[0].to).toEqual({ x: 100, y: 200000 }); }); async function loadProject(page: Page, ports: boolean, rotatedTarget = false, block = false) { await prepareApp(page); if (!ports) { const catalog = structuredClone(componentCatalog); catalog.libraries.flatMap((library) => library.components) .find((component) => component.type === "generic_sensor")!.ports = []; await page.route("**/api/components/catalog", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(catalog), })); } const nodes = [ { id: "moving", x: 0, y: 0, rotation: 0 }, { id: "reference", x: 400, y: block ? 400 : 210, rotation: rotatedTarget ? 90 : 0 }, ...(block ? [{ id: "partner", x: 0, y: 130, rotation: 0 }] : []), ].map(({ id, x, y, rotation }) => ({ ...structuredClone(wideProject.nodes[0]), id, position: { x, y }, data: { ...structuredClone(wideProject.nodes[0].data), label: id, rotation, ports: ports ? wideProject.nodes[0].data.ports : [] }, })); await page.addInitScript(({ key, project }) => localStorage.setItem(key, JSON.stringify(project)), { key: KEY, project: { ...structuredClone(wideProject), nodes, edges: block ? [{ ...wideProject.edges[0], source: "moving", target: "partner", data: { isContactEdge: false, routePoints: [{ x: 200, y: 50 }, { x: 200, y: 170 }] }, }] : [] }, }); await page.goto("/"); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await expectAllNodesInsideCanvas(page, ".flow-canvas", block ? 3 : 2); return { moving: page.locator('.react-flow__node[data-id="moving"]'), reference: page.locator('.react-flow__node[data-id="reference"]'), guides: page.locator(".alignment-guides"), }; } async function bounds(locator: Locator) { const box = await locator.boundingBox(); expect(box).not.toBeNull(); return { ...box!, cx: box!.x + box!.width / 2, cy: box!.y + box!.height / 2 }; } async function beginDrag(page: Page, node: Locator) { const before = await bounds(node); await page.mouse.move(before.cx, before.cy); await page.mouse.down(); // 先跨过 React Flow 的拖拽启动阈值,再按实际抓取偏移定位。 await page.mouse.move(before.cx, before.cy + 5); const after = await bounds(node); return { x: before.cx - after.cx, y: before.cy + 5 - after.cy }; } for (const kind of ["center", "origin", "port"] as const) { test(`拖动时显示${kind}辅助线并精确吸附,松手后隐藏,支持撤销`, async ({ page }) => { const { moving, reference, guides } = await loadProject(page, kind === "port", kind !== "center"); const before = await bounds(moving); const target = await bounds(reference); const portSelector = '.port-handle[data-port-name="port_b"]'; const sourceAnchor = kind === "port" ? (await bounds(moving.locator(portSelector))).cy : kind === "center" ? before.cy : before.y; const targetAnchor = kind === "port" ? (await bounds(reference.locator(portSelector))).cy : kind === "center" ? target.cy : target.y; const grab = await beginDrag(page, moving); await page.mouse.move(before.cx + grab.x, before.cy + targetAnchor - sourceAnchor + 4 + grab.y, { steps: 8 }); await expect(guides.locator(`[data-alignment-kind="${kind}"]`)).toBeVisible(); await expect(guides.locator("text")).toHaveCount(0); await expect(guides).toHaveCSS("opacity", "0.5"); await expect(guides).toHaveCSS("pointer-events", "none"); const snapped = await bounds(moving); const snappedAnchor = kind === "port" ? (await bounds(moving.locator(portSelector))).cy : kind === "center" ? snapped.cy : snapped.y; expect(Math.abs(snappedAnchor - targetAnchor)).toBeLessThan(0.7); const line = guides.locator('[data-alignment-axis="y"] line'); expect(await line.getAttribute("y1")).toBe(await line.getAttribute("y2")); await expect(line).toHaveCSS("stroke", "rgb(128, 197, 239)"); await expect(line).toHaveCSS("stroke-width", "4px"); await expect(line).toHaveCSS("stroke-dasharray", "none"); await expect(line).toHaveCSS("vector-effect", "non-scaling-stroke"); await expect(guides.locator("circle").first()).toHaveAttribute("r", "5"); await expect(guides.locator("circle").first()).toHaveCSS("fill", "rgb(128, 197, 239)"); await expect(guides.locator("circle").first()).toHaveCSS("stroke", "none"); if (kind === "center") await page.screenshot({ path: "test-results/alignment-preview.png" }); await page.mouse.up(); await expect(guides).toHaveCount(0); expect(Math.abs((await bounds(moving)).y - snapped.y)).toBeLessThan(0.7); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); await page.keyboard.press("Control+z"); expect(Math.abs((await bounds(moving)).y - before.y)).toBeLessThan(0.7); }); } test("离开范围、按 Alt 或关闭自动对齐时自由拖动且不残留辅助线", async ({ page }) => { const { moving, reference, guides } = await loadProject(page, false); const start = await bounds(moving); const target = await bounds(reference); const grab = await beginDrag(page, moving); await page.mouse.move(start.cx + grab.x, target.cy + 4 + grab.y, { steps: 8 }); await expect(guides).toBeVisible(); await page.keyboard.down("Alt"); await page.mouse.move(start.cx + grab.x, target.cy + 5 + grab.y); await expect(guides).toHaveCount(0); expect((await bounds(moving)).cy - target.cy).toBeGreaterThan(3); await page.keyboard.up("Alt"); await page.mouse.move(start.cx + grab.x, target.cy + 3 + grab.y); await expect(guides).toBeVisible(); await page.mouse.move(start.cx + grab.x, target.cy + 35 + grab.y); await expect(guides).toHaveCount(0); await page.mouse.up(); await page.getByRole("button", { name: "关闭自动对齐(拖动时 Alt 暂停)", exact: true }).click(); const current = await bounds(moving); const nextGrab = await beginDrag(page, moving); await page.mouse.move(current.cx + nextGrab.x, target.cy + 4 + nextGrab.y, { steps: 5 }); await expect(guides).toHaveCount(0); await page.mouse.up(); expect((await bounds(moving)).cy - target.cy).toBeGreaterThan(2); }); test("多选块对齐保持相对位置,内部折线路径整体平移,保存重载仍一致", async ({ page }) => { const { moving, reference, guides } = await loadProject(page, true, false, true); const partner = page.locator('.react-flow__node[data-id="partner"]'); const before = await bounds(moving); const partnerBefore = await bounds(partner); const target = await bounds(reference); await moving.locator(".sim-node").click(); await partner.locator(".sim-node").click({ modifiers: ["Control"] }); const grab = await beginDrag(page, moving); await page.mouse.move(target.cx + 4 + grab.x, before.cy + grab.y, { steps: 8 }); await expect(guides.locator('[data-alignment-axis="x"]')).toBeVisible(); const moved = await bounds(moving); const partnerMoved = await bounds(partner); expect(Math.abs(moved.cx - target.cx)).toBeLessThan(0.7); expect(Math.abs(partnerMoved.x - moved.x - (partnerBefore.x - before.x))).toBeLessThan(0.7); expect(Math.abs(partnerMoved.y - moved.y - (partnerBefore.y - before.y))).toBeLessThan(0.7); await page.mouse.up(); await expect(guides).toHaveCount(0); await page.getByRole("button", { name: "保存工程", exact: true }).click(); const project = await page.evaluate((key) => JSON.parse(localStorage.getItem(key)!), KEY); const first = project.nodes.find((node: { id: string }) => node.id === "moving"); const second = project.nodes.find((node: { id: string }) => node.id === "partner"); expect(first.position.x).toBeCloseTo(400, 1); expect(second.position.x).toBeCloseTo(first.position.x, 3); expect(second.position.y - first.position.y).toBeCloseTo(130, 3); expect(project.edges[0].data.routePoints).toEqual([ { x: 200 + first.position.x, y: 50 + first.position.y }, { x: 200 + first.position.x, y: 170 + first.position.y }, ]); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await expectAllNodesInsideCanvas(page, ".flow-canvas", 3); expect(Math.abs((await bounds(moving)).cx - (await bounds(reference)).cx)).toBeLessThan(0.7); }); test("接触端口吸附优先,显示端口辅助线,只有落下后才建立连接", async ({ page }) => { const { moving, reference, guides } = await loadProject(page, true); const start = await bounds(moving); const source = await bounds(moving.locator('.port-handle[data-port-name="port_b"]')); const target = await bounds(reference.locator('.port-handle[data-port-name="port_a"]')); const grab = await beginDrag(page, moving); await page.mouse.move(start.cx + target.cx - source.cx - 5 + grab.x, start.cy + target.cy - source.cy + 4 + grab.y, { steps: 12 }); await expect(guides.locator('[data-alignment-kind="port"]')).toHaveCount(2); await expect(guides.locator("text")).toHaveCount(0); await expect(guides).toHaveCSS("opacity", "0.5"); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); await page.mouse.up(); await expect(guides).toHaveCount(0); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); const connectedSource = await bounds(moving.locator('.port-handle[data-port-name="port_b"]')); expect(Math.hypot(connectedSource.cx - target.cx, connectedSource.cy - target.cy)).toBeLessThan(0.7); });