import { expect, test } from "@playwright/test"; import { prepareApp, wideProject } from "./fixtures"; test.beforeEach(async ({ page }) => { await prepareApp(page); }); test("未知图形元件使用带端口和名称的默认节点且拖入动作不污染仿真控制台", async ({ page, }) => { await page.goto("/"); const paletteItem = page.getByRole("button", { name: /通用测试元件/ }); await expect(paletteItem).toBeVisible(); await paletteItem.dragTo(page.locator(".flow-canvas .react-flow__pane")); const node = page.locator('.flow-canvas .react-flow__node[data-id="generic_sensor_1"]'); await expect(node).toBeVisible(); await expect(node.locator(".sim-node")).toHaveClass(/generic-component/); await expect(node.locator(".node-symbol-anchor")).toHaveCount(0); await expect(node.locator(".node-label")).toHaveText("generic_sensor_1"); await expect(node.locator(".port-handle")).toHaveCount(2); await expect( node.locator('.port-index-label[data-port-name="port_a"]'), ).toHaveText("1"); await expect( node.locator('.port-index-label[data-port-name="port_b"]'), ).toHaveText("2"); const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true, }); await expect(consolePanel).toHaveClass(/minimized/); await expect(consolePanel).toHaveCSS("height", "40px"); await expect(consolePanel).not.toHaveClass(/has-unread/); await expect(consolePanel.locator(".simulation-console-unread")).toHaveCount(0); await expect(consolePanel.locator(".simulation-console-dock-summary")).toHaveText( "等待仿真任务", ); await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0); await page.getByRole("button", { name: "生成系统 XML" }).click(); await expect(consolePanel).toHaveClass(/minimized/); await expect(consolePanel).toHaveCSS("height", "40px"); await expect(consolePanel.locator(".simulation-console-dock-summary")).toHaveText( "系统 XML 已生成", ); await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0); await page.getByRole("button", { name: "展开仿真控制台" }).click(); const xmlBlock = consolePanel.getByLabel("生成的系统 XML"); await expect(xmlBlock).toBeVisible(); await expect(xmlBlock).toContainText(''); await expect(xmlBlock).toContainText('schemaVersion="3"'); await expect(xmlBlock).toContainText('unitSystem="SI"'); await expect(xmlBlock).toContainText('sampleStep="0.1"'); await expect(xmlBlock).toContainText( ' { await page.goto("/"); await page .getByRole("button", { name: /通用测试元件/ }) .dragTo(page.locator(".flow-canvas .react-flow__pane")); await page.getByRole("button", { name: "保存工程", exact: true }).click(); await page.evaluate(() => { const key = "system-simulation-flow:autosave"; const raw = window.localStorage.getItem(key); if (!raw) { throw new Error("测试工程未保存"); } const project = JSON.parse(raw) as { nodes: Array<{ data: { parameters: Record } }>; }; project.nodes[0].data.parameters.typoGain = 2; window.localStorage.setItem(key, JSON.stringify(project)); }); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await page.getByRole("button", { name: "展开仿真控制台" }).click(); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true, }); await expect(consolePanel).toContainText("加载失败"); await expect(consolePanel).toContainText("未知参数 typoGain"); await expect(consolePanel.getByLabel("生成的系统 XML")).toContainText(' { await page.goto("/"); await page .getByRole("button", { name: /通用测试元件/ }) .dragTo(page.locator(".flow-canvas .react-flow__pane")); await page.getByRole("button", { name: "保存工程", exact: true }).click(); await page.evaluate(() => { const key = "system-simulation-flow:autosave"; const raw = window.localStorage.getItem(key); if (!raw) { throw new Error("测试工程未保存"); } const project = JSON.parse(raw) as { nodes: Array<{ data: { parameters: Record } }>; }; project.nodes[0].data.parameters.gain = 1.2345678901234567; window.localStorage.setItem(key, JSON.stringify(project)); }); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await page.getByRole("button", { name: "展开仿真控制台" }).click(); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); await expect(page.getByLabel("生成的系统 XML")).toContainText( ' { await page.goto("/"); const paletteItem = page.getByRole("button", { name: /通用测试元件/ }); const pane = page.locator(".flow-canvas .react-flow__pane"); await paletteItem.dragTo(pane, { targetPosition: { x: 220, y: 220 } }); const firstNode = page.locator( '.flow-canvas .react-flow__node[data-id="generic_sensor_1"]', ); await expect(firstNode).toHaveClass(/selected/); await expect(firstNode.locator(".sim-node")).toHaveCSS( "outline-style", "dashed", ); await paletteItem.dragTo(pane, { targetPosition: { x: 480, y: 340 } }); const secondNode = page.locator( '.flow-canvas .react-flow__node[data-id="generic_sensor_2"]', ); await expect(firstNode).not.toHaveClass(/selected/); await expect(secondNode).toHaveClass(/selected/); await expect(firstNode.locator(".sim-node")).toHaveCSS("outline-style", "none"); await expect(secondNode.locator(".sim-node")).toHaveCSS( "outline-style", "dashed", ); await page .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) .click(); await expect(firstNode.locator(".sim-node")).not.toHaveClass( /orientation-vertical/, ); await expect(secondNode.locator(".sim-node")).toHaveClass( /orientation-vertical/, ); }); test("复制后粘贴进入待放置状态,跟随鼠标并在左键确认时写入工程", async ({ page, }) => { await page.goto("/"); const pane = page.locator(".flow-canvas .react-flow__pane"); await page .getByRole("button", { name: /通用测试元件/ }) .dragTo(pane, { targetPosition: { x: 250, y: 220 } }); const original = page.locator( '.flow-canvas .react-flow__node[data-id="generic_sensor_1"]', ); await original.click(); await page.keyboard.press("Control+c"); const paneBounds = await pane.boundingBox(); expect(paneBounds).not.toBeNull(); const firstPointer = { x: paneBounds!.x + paneBounds!.width * 0.62, y: paneBounds!.y + paneBounds!.height * 0.54, }; await page.mouse.move(firstPointer.x, firstPointer.y); await page.keyboard.press("Control+v"); const preview = page.locator( '.flow-canvas .react-flow__node[data-id="generic_sensor_2"]', ); await expect(preview).toHaveClass(/pending-paste-node/); await expect(page.getByText(/待放置:左键放置/)).toBeVisible(); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(2); await expect(page.getByLabel("组件名称")).toHaveCount(0); await expect(page.getByRole("button", { name: "撤销 (Ctrl+Z)" })).toBeDisabled(); await expect(page.getByRole("button", { name: "复制 (Ctrl+C)" })).toBeDisabled(); await expect(page.getByRole("button", { name: "粘贴 (Ctrl+V)" })).toBeDisabled(); await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeDisabled(); const previewBeforeMove = await preview.boundingBox(); expect(previewBeforeMove).not.toBeNull(); await page.mouse.move(firstPointer.x + 90, firstPointer.y + 55, { steps: 4 }); await expect .poll(async () => (await preview.boundingBox())?.x ?? 0) .toBeGreaterThan(previewBeforeMove!.x + 40); const viewport = page.locator(".flow-canvas .react-flow__viewport"); const viewportBeforeZoom = await viewport.getAttribute("style"); await page.keyboard.down("Control"); await page.mouse.wheel(0, -160); await page.keyboard.up("Control"); await expect(viewport).not.toHaveAttribute("style", viewportBeforeZoom ?? ""); await expect(preview).toHaveClass(/pending-paste-node/); const previewBeforeScroll = await preview.boundingBox(); expect(previewBeforeScroll).not.toBeNull(); const horizontalScrollbar = page.getByRole("scrollbar", { name: "水平滚动画布", }); const viewportBeforeScroll = await viewport.getAttribute("style"); await horizontalScrollbar.focus(); await page.keyboard.press("ArrowRight"); await expect(viewport).not.toHaveAttribute("style", viewportBeforeScroll ?? ""); await expect(preview).toHaveClass(/pending-paste-node/); const previewAfterScroll = await preview.boundingBox(); expect(previewAfterScroll).not.toBeNull(); expect( Math.abs( previewAfterScroll!.x + previewAfterScroll!.width / 2 - (previewBeforeScroll!.x + previewBeforeScroll!.width / 2), ), ).toBeLessThanOrEqual(10); expect( Math.abs( previewAfterScroll!.y + previewAfterScroll!.height / 2 - (previewBeforeScroll!.y + previewBeforeScroll!.height / 2), ), ).toBeLessThanOrEqual(10); const verticalBefore = await preview.locator(".sim-node").evaluate((element) => element.classList.contains("orientation-vertical"), ); const previewBounds = await preview.boundingBox(); expect(previewBounds).not.toBeNull(); await page.mouse.click( previewBounds!.x + previewBounds!.width / 2, previewBounds!.y + previewBounds!.height / 2, { button: "middle" }, ); await expect .poll(() => preview.locator(".sim-node").evaluate((element) => element.classList.contains("orientation-vertical"), ), ) .not.toBe(verticalBefore); const finalBounds = await preview.boundingBox(); expect(finalBounds).not.toBeNull(); await page.mouse.click( finalBounds!.x + finalBounds!.width / 2, finalBounds!.y + finalBounds!.height / 2, ); await expect(preview).not.toHaveClass(/pending-paste-node/); await expect(preview).toHaveClass(/selected/); await expect(page.getByLabel("组件名称")).toHaveValue("generic_sensor_1_副本"); }); test("复制已连接组件时副本 ID 避让画布中的同名节点且不会移动原元件", async ({ page, }) => { const project = structuredClone(wideProject); project.nodes = project.nodes.slice(0, 2); project.edges = project.edges.slice(0, 1); project.nodes[1] = { ...project.nodes[1], data: { ...project.nodes[1].data, label: "amesim_pneumatic_test_1", componentType: "amesim_pneumatic_test", modelType: "amesim_pneumatic_test", }, }; await page.addInitScript((storedProject) => { window.localStorage.setItem( "system-simulation-flow:autosave", JSON.stringify(storedProject), ); }, project); await page.goto("/"); await page.getByRole("button", { name: "加载工程" }).click(); const firstNode = page.locator( '.flow-canvas .react-flow__node[data-id="generic_sensor_1"]', ); const occupiedIdNode = page.locator( '.flow-canvas .react-flow__node[data-id="generic_sensor_2"]', ); await expect(firstNode).toBeVisible(); await expect(occupiedIdNode).toBeVisible(); const firstBoundsBefore = await firstNode.boundingBox(); const occupiedBoundsBefore = await occupiedIdNode.boundingBox(); expect(firstBoundsBefore).not.toBeNull(); expect(occupiedBoundsBefore).not.toBeNull(); await firstNode.click(); await page.keyboard.press("Control+c"); 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 * 0.62, paneBounds!.y + paneBounds!.height * 0.68, ); await page.keyboard.press("Control+v"); const preview = page.locator( '.flow-canvas .react-flow__node[data-id="generic_sensor_3"]', ); await expect(preview).toHaveClass(/pending-paste-node/); await expect(preview.locator(".node-label")).toHaveText( "generic_sensor_1_副本", ); await expect(occupiedIdNode).not.toHaveClass(/pending-paste-node/); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(3); const previewBounds = await preview.boundingBox(); expect(previewBounds).not.toBeNull(); await page.mouse.click( previewBounds!.x + previewBounds!.width / 2, previewBounds!.y + previewBounds!.height / 2, ); await expect(preview).not.toHaveClass(/pending-paste-node/); await expect(preview).toHaveClass(/selected/); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); const firstBoundsAfter = await firstNode.boundingBox(); const occupiedBoundsAfter = await occupiedIdNode.boundingBox(); expect(firstBoundsAfter).toEqual(firstBoundsBefore); expect(occupiedBoundsAfter).toEqual(occupiedBoundsBefore); const nodeIds = await page .locator(".flow-canvas .react-flow__node") .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-id"))); expect(new Set(nodeIds).size).toBe(nodeIds.length); }); test("控制台文本选择使用浏览器原生复制,不触发元件复制", async ({ page }) => { await page.goto("/"); await page .getByRole("button", { name: /通用测试元件/ }) .dragTo(page.locator(".flow-canvas .react-flow__pane")); const node = page.locator( '.flow-canvas .react-flow__node[data-id="generic_sensor_1"]', ); await node.click(); await page.getByRole("button", { name: "展开仿真控制台" }).click(); await page.getByRole("button", { name: "生成系统 XML" }).click(); const xml = page.getByLabel("生成的系统 XML"); await expect(xml).toBeVisible(); await xml.evaluate((element) => { const range = document.createRange(); range.selectNodeContents(element); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); }); const copiedModelMessagesBefore = await page .locator(".simulation-console-dock-entry-message", { hasText: "已复制" }) .count(); await page.keyboard.press("Control+c"); await expect(page.locator(".flow-canvas .pending-paste-node")).toHaveCount(0); await expect .poll(() => page .locator(".simulation-console-dock-entry-message", { hasText: "已复制" }) .count(), ) .toBe(copiedModelMessagesBefore); }); test("底部仿真控制台可以调整高度并折叠显示摘要", async ({ page }) => { await page.goto("/"); const workbench = page.locator(".workbench"); const palette = page.locator(".palette"); const canvas = page.locator(".flow-canvas"); const properties = page.locator(".properties"); const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true, }); const expectClose = (actual: number, expected: number, tolerance = 2) => { expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance); }; await expect(consolePanel).toHaveClass(/minimized/); await expect(consolePanel).toHaveCSS("height", "40px"); const initialWorkbench = await workbench.boundingBox(); const initialPalette = await palette.boundingBox(); const initialCanvas = await canvas.boundingBox(); const initialProperties = await properties.boundingBox(); const initialConsole = await consolePanel.boundingBox(); expect(initialWorkbench).not.toBeNull(); expect(initialPalette).not.toBeNull(); expect(initialCanvas).not.toBeNull(); expect(initialProperties).not.toBeNull(); expect(initialConsole).not.toBeNull(); expectClose(initialConsole!.x, initialCanvas!.x); expectClose(initialConsole!.width, initialCanvas!.width); expectClose(initialConsole!.y, initialCanvas!.y + initialCanvas!.height); expectClose( initialConsole!.y + initialConsole!.height, initialWorkbench!.y + initialWorkbench!.height, ); expectClose(initialPalette!.y, initialWorkbench!.y); expectClose(initialPalette!.height, initialWorkbench!.height); expectClose(initialProperties!.y, initialWorkbench!.y); expectClose(initialProperties!.height, initialWorkbench!.height); await page.getByRole("button", { name: "展开仿真控制台" }).click(); await expect(consolePanel).toHaveClass(/normal/); const expandedCanvas = await canvas.boundingBox(); const expandedConsole = await consolePanel.boundingBox(); const expandedPalette = await palette.boundingBox(); const expandedProperties = await properties.boundingBox(); expect(expandedCanvas).not.toBeNull(); expect(expandedConsole).not.toBeNull(); expect(expandedPalette).not.toBeNull(); expect(expandedProperties).not.toBeNull(); expect(expandedCanvas!.height).toBeLessThan(initialCanvas!.height - 100); expectClose(expandedConsole!.x, expandedCanvas!.x); expectClose(expandedConsole!.width, expandedCanvas!.width); expectClose(expandedPalette!.y, initialPalette!.y); expectClose(expandedPalette!.height, initialPalette!.height); expectClose(expandedProperties!.y, initialProperties!.y); expectClose(expandedProperties!.height, initialProperties!.height); const resizer = page.getByRole("separator", { name: "调整仿真控制台高度", }); const resizerBox = await resizer.boundingBox(); expect(resizerBox).not.toBeNull(); await page.mouse.move(resizerBox!.x + resizerBox!.width / 2, resizerBox!.y + 3); await page.mouse.down(); await page.mouse.move(resizerBox!.x + resizerBox!.width / 2, resizerBox!.y - 55, { steps: 5, }); await page.mouse.up(); await expect .poll(async () => (await consolePanel.boundingBox())?.height ?? 0) .toBeGreaterThan(expandedConsole!.height + 35); const resizedConsole = await consolePanel.boundingBox(); const resizedCanvas = await canvas.boundingBox(); expect(resizedConsole).not.toBeNull(); expect(resizedCanvas).not.toBeNull(); expectClose( resizedConsole!.height - expandedConsole!.height, expandedCanvas!.height - resizedCanvas!.height, 3, ); await page.getByRole("button", { name: "收起仿真控制台" }).click(); await expect(consolePanel).toHaveCSS("height", "40px"); await expect(consolePanel.locator(".simulation-console-dock-summary")).not.toBeEmpty(); const collapsedCanvas = await canvas.boundingBox(); expect(collapsedCanvas).not.toBeNull(); expectClose(collapsedCanvas!.height, initialCanvas!.height); }); test("框选结束后隐藏整体包围框并保留每个组件的虚线选中框", async ({ page, }) => { await page.goto("/"); const paletteItem = page.getByRole("button", { name: /通用测试元件/ }); const pane = page.locator(".flow-canvas .react-flow__pane"); await paletteItem.dragTo(pane, { targetPosition: { x: 220, y: 220 } }); await paletteItem.dragTo(pane, { targetPosition: { x: 480, y: 340 } }); const firstNode = page.locator( '.flow-canvas .react-flow__node[data-id="generic_sensor_1"]', ); const secondNode = page.locator( '.flow-canvas .react-flow__node[data-id="generic_sensor_2"]', ); const [paneBounds, firstBounds, secondBounds] = await Promise.all([ pane.boundingBox(), firstNode.boundingBox(), secondNode.boundingBox(), ]); expect(paneBounds).not.toBeNull(); expect(firstBounds).not.toBeNull(); expect(secondBounds).not.toBeNull(); const startX = Math.max( paneBounds!.x + 4, Math.min(firstBounds!.x, secondBounds!.x) - 14, ); const startY = Math.max( paneBounds!.y + 4, Math.min(firstBounds!.y, secondBounds!.y) - 14, ); const endX = Math.min( paneBounds!.x + paneBounds!.width - 4, Math.max( firstBounds!.x + firstBounds!.width, secondBounds!.x + secondBounds!.width, ) + 14, ); const endY = Math.min( paneBounds!.y + paneBounds!.height - 4, Math.max( firstBounds!.y + firstBounds!.height, secondBounds!.y + secondBounds!.height, ) + 14, ); await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(endX, endY, { steps: 12 }); await page.mouse.up(); await expect(firstNode).toHaveClass(/selected/); await expect(secondNode).toHaveClass(/selected/); await expect(firstNode.locator(".sim-node")).toHaveCSS( "outline-style", "dashed", ); await expect(secondNode.locator(".sim-node")).toHaveCSS( "outline-style", "dashed", ); await expect(page.locator(".flow-canvas .react-flow__selection")).toHaveCount(0); const aggregateSelection = page.locator( ".flow-canvas .react-flow__nodesselection-rect", ); await expect(aggregateSelection).toHaveCount(1); await expect(aggregateSelection).toHaveCSS("border-top-width", "0px"); await expect(aggregateSelection).toHaveCSS("pointer-events", "none"); });