import { expect, test, type Locator, type Page } from "@playwright/test"; import { componentCatalog, prepareApp, wideProject } from "./fixtures"; const VERTICAL_MIRROR = "竖直镜像 (Shift+V)"; const horizontalMirror = (page: Page) => page.getByRole("button", { name: "水平镜像 (Shift+H)", exact: true }); const verticalMirror = (page: Page) => page.getByRole("button", { name: VERTICAL_MIRROR, exact: true }); const nodeById = (page: Page, id: string) => page.locator(`.flow-canvas .react-flow__node[data-id="${id}"]`); const extraPort = { ...wideProject.nodes[0].data.ports[0], name: "port_c" }; function mirrorProject(rotation = 0, mirrored = false) { return { ...structuredClone(wideProject), name: "vertical-mirror", nodes: wideProject.nodes.slice(0, 2).map((node, index) => ({ ...structuredClone(node), position: { x: index * 420, y: index * 240 }, data: { ...structuredClone(node.data), ports: [...node.data.ports, extraPort], rotation: index === 0 ? rotation : 90, mirrored: index === 0 ? mirrored : true, }, })), edges: [{ id: "mirror-edge", source: "generic_sensor_1", target: "generic_sensor_2", sourceHandle: "port_a", targetHandle: "port_b", data: { isContactEdge: false }, }], }; } test.beforeEach(async ({ page }) => { await prepareApp(page); // Two left ports make the component asymmetric about its horizontal centerline. const catalog = { ...componentCatalog, libraries: componentCatalog.libraries.map((library) => ({ ...library, components: library.components.map((component) => component.type === "generic_sensor" ? { ...component, ports: [...component.ports, extraPort] } : component), })), }; await page.route("**/api/components/catalog", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(catalog), })); }); async function importMirrorProject(page: Page, project: ReturnType) { await page.goto("/"); await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible(); await page.locator('input[type="file"]').setInputFiles({ name: "vertical-mirror.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(project)), }); await expect(nodeById(page, "generic_sensor_1")).toBeVisible(); } async function saveProject(page: Page) { await page.getByRole("button", { name: "保存工程", exact: true }).click(); return page.evaluate(() => JSON.parse(window.localStorage.getItem( "system-simulation-flow:autosave", )!)) as Promise>; } async function nodeGeometry(node: Locator) { return node.evaluate((element) => { const body = element.querySelector(".sim-node")!; const frame = element.querySelector(".sim-node-frame")!; const matrix = new DOMMatrixReadOnly(getComputedStyle(frame).transform); const width = Number(body.dataset.nodeLayoutWidth); const height = Number(body.dataset.nodeLayoutHeight); return { width, height, matrix: { a: matrix.a, b: matrix.b, c: matrix.c, d: matrix.d }, ports: Array.from(element.querySelectorAll(".port-handle")).map((port) => ({ name: port.dataset.handleid, x: parseFloat(port.style.left) - width / 2, y: parseFloat(port.style.top) - height / 2, })), }; }); } async function expectEdgeAttached(page: Page) { await expect.poll(() => page.evaluate(() => { const path = document.querySelector( '.flow-canvas .react-flow__edge[data-id="mirror-edge"] .react-flow__edge-path', ); const handle = document.querySelector( '.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-handleid="port_a"]', ); const matrix = path?.getScreenCTM(); if (!path || !handle || !matrix) return Infinity; const point = path.getPointAtLength(0).matrixTransform(matrix); const bounds = handle.getBoundingClientRect(); // React Flow attaches the path to the outer edge of a Handle, not its center. const x = handle.classList.contains("react-flow__handle-left") ? bounds.left : handle.classList.contains("react-flow__handle-right") ? bounds.right : bounds.left + bounds.width / 2; const y = handle.classList.contains("react-flow__handle-top") ? bounds.top : handle.classList.contains("react-flow__handle-bottom") ? bounds.bottom : bounds.top + bounds.height / 2; return Math.hypot(point.x - x, point.y - y); })).toBeLessThan(1); } for (const rotation of [0, 90, 180, 270]) { for (const mirrored of [false, true]) { test(`竖直镜像始终上下翻转画布组件与端口:旋转 ${rotation}°,镜像 ${mirrored}`, async ({ page }) => { const project = mirrorProject(rotation, mirrored); await importMirrorProject(page, project); await expect(verticalMirror(page)).toBeDisabled(); const first = nodeById(page, "generic_sensor_1"); const second = nodeById(page, "generic_sensor_2"); await first.click(); await expect(verticalMirror(page)).toBeEnabled(); const [horizontalBox, verticalBox] = await Promise.all([ horizontalMirror(page).boundingBox(), verticalMirror(page).boundingBox(), ]); expect(verticalBox!.x).toBeGreaterThan(horizontalBox!.x); expect(verticalBox!.x - horizontalBox!.x - horizontalBox!.width).toBeLessThan(16); const before = await nodeGeometry(first); const otherBefore = await nodeGeometry(second); const path = page.locator('.react-flow__edge[data-id="mirror-edge"] .react-flow__edge-path'); const pathBefore = await path.getAttribute("d"); await verticalMirror(page).click(); const after = await nodeGeometry(first); expect(after.width).toBe(before.width); expect(after.height).toBe(before.height); expect(after.matrix.a).toBeCloseTo(before.matrix.a, 8); expect(after.matrix.b).toBeCloseTo(-before.matrix.b, 8); expect(after.matrix.c).toBeCloseTo(before.matrix.c, 8); expect(after.matrix.d).toBeCloseTo(-before.matrix.d, 8); after.ports.forEach((port, index) => { expect(port.x).toBeCloseTo(before.ports[index].x, 6); expect(port.y).toBeCloseTo(-before.ports[index].y, 6); }); expect(await nodeGeometry(second)).toEqual(otherBefore); await expect(path).not.toHaveAttribute("d", pathBefore!); await expectEdgeAttached(page); const saved = await saveProject(page); expect(saved.nodes[0].data.rotation).toBe((540 - rotation) % 360); expect(saved.nodes[0].data.mirrored).toBe(!mirrored); expect(saved.nodes[0].position).toEqual(project.nodes[0].position); expect(saved.nodes[0].data.parameters).toEqual(project.nodes[0].data.parameters); expect(saved.edges[0]).toMatchObject(project.edges[0]); await verticalMirror(page).click(); expect(await nodeGeometry(first)).toEqual(before); await expectEdgeAttached(page); }); } } test("竖直镜像支持混合方向多选、快捷键、撤销重做和工程加载", async ({ page }) => { const project = mirrorProject(); await importMirrorProject(page, project); await nodeById(page, "generic_sensor_1").click(); await page.keyboard.press("Control+a"); await expect(page.locator(".flow-canvas .react-flow__node.selected")).toHaveCount(2); await page.keyboard.press("Shift+v"); const mirrored = await saveProject(page); expect(mirrored.nodes.map((node) => [node.data.rotation, node.data.mirrored])).toEqual([ [180, true], [90, false], ]); await page.getByRole("button", { name: "撤销 (Ctrl+Z)", exact: true }).click(); let saved = await saveProject(page); expect(saved.nodes.map((node) => [node.data.rotation, node.data.mirrored])).toEqual([ [0, false], [90, true], ]); await page.getByRole("button", { name: "重做 (Ctrl+Y)", exact: true }).click(); saved = await saveProject(page); expect(saved.nodes).toEqual(mirrored.nodes); await page.getByRole("button", { name: "加载工程", exact: true }).click(); expect((await saveProject(page)).nodes).toEqual(mirrored.nodes); await nodeById(page, "generic_sensor_1").click(); await page.keyboard.press("Control+a"); await verticalMirror(page).click(); saved = await saveProject(page); expect(saved.nodes.map((node) => [node.data.rotation, node.data.mirrored])).toEqual([ [0, false], [90, true], ]); expect(saved.nodes.map((node) => node.position)).toEqual(project.nodes.map((node) => node.position)); await expectEdgeAttached(page); }); test("竖直镜像使接触端口分离时断开连接,撤销恢复端口接触", async ({ page }) => { const project = mirrorProject(); project.nodes[0].position = { x: 300, y: 200 }; project.nodes[1].data.rotation = 0; project.nodes[1].data.mirrored = false; await importMirrorProject(page, project); const sourceGeometry = await nodeGeometry(nodeById(page, "generic_sensor_1")); const targetGeometry = await nodeGeometry(nodeById(page, "generic_sensor_2")); const sourcePort = sourceGeometry.ports.find((port) => port.name === "port_a")!; const targetPort = targetGeometry.ports.find((port) => port.name === "port_b")!; project.nodes[1].position = { x: project.nodes[0].position.x + sourceGeometry.width / 2 + sourcePort.x - targetGeometry.width / 2 - targetPort.x, y: project.nodes[0].position.y + sourceGeometry.height / 2 + sourcePort.y - targetGeometry.height / 2 - targetPort.y, }; project.edges[0].data.isContactEdge = true; await page.locator('input[type="file"]').setInputFiles({ name: "vertical-mirror.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(project)), }); const source = nodeById(page, "generic_sensor_1"); const handle = source.locator('.port-handle[data-handleid="port_a"]'); await expect(handle).toHaveAttribute("data-connected", "true"); const before = await saveProject(page); expect(before.edges).toHaveLength(1); expect(before.edges[0].data.isContactEdge).toBe(true); await source.click(); await verticalMirror(page).click(); await expect(handle).toHaveAttribute("data-connected", "false"); expect((await saveProject(page)).edges).toHaveLength(0); await page.getByRole("button", { name: "撤销 (Ctrl+Z)", exact: true }).click(); await expect(handle).toHaveAttribute("data-connected", "true"); const restored = await saveProject(page); expect(restored.nodes).toEqual(before.nodes); expect(restored.edges).toEqual(before.edges); });