import { expect, test, type Page } from "@playwright/test"; import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { expectAllNodesInsideCanvas, prepareApp, resultSnapshot, wideProject, } from "./fixtures"; type ImportedProjectEdge = { id: string; source: string; target: string; sourceHandle: string; targetHandle: string; data?: { isContactEdge?: boolean }; }; type ImportedProject = { nodes: Array<{ id: string }>; edges: ImportedProjectEdge[]; }; const MQL_8_PROJECT_PATHS = [ new URL("../../../tests/data/test-mql-8%20.json", import.meta.url), new URL("../../../tests/data/test-mql-8.json", import.meta.url), ].map(fileURLToPath); const MQL_8_PROJECT_PATH = MQL_8_PROJECT_PATHS.find(existsSync) ?? MQL_8_PROJECT_PATHS[0]; const mql8Project = JSON.parse( readFileSync(MQL_8_PROJECT_PATH, "utf8"), ) as ImportedProject; const mql8ResultSnapshot = { ...resultSnapshot, id: "e2e-mql-8-result-system-bridges", project: mql8Project as unknown as typeof resultSnapshot.project, }; async function readViewport(page: Page, canvasSelector: string) { return page .locator(`${canvasSelector} .react-flow__viewport`) .evaluate((viewport) => { const values = getComputedStyle(viewport) .transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?.map(Number) ?? []; return { x: values[4] ?? 0, y: values[5] ?? 0, zoom: values[0] ?? 1, }; }); } async function readModelingViewport(page: Page) { return readViewport(page, ".flow-canvas"); } async function readVisibleEdgeEndpointAlignment( page: Page, edges: ImportedProjectEdge[], ) { return page.evaluate((projectEdges) => { const nodeElements = new Map( Array.from( document.querySelectorAll( ".flow-canvas .react-flow__node[data-id]", ), ).flatMap((element) => element.dataset.id ? [[element.dataset.id, element] as const] : [], ), ); const edgeElements = new Map( Array.from( document.querySelectorAll( ".flow-canvas .react-flow__edge[data-id]", ), ).flatMap((element) => element.dataset.id ? [[element.dataset.id, element] as const] : [], ), ); const measurements: Array<{ edgeId: string; sourceDistance: number; targetDistance: number; }> = []; const missingHandles: string[] = []; for (const edge of projectEdges) { if (edge.data?.isContactEdge === true) { continue; } const path = edgeElements .get(edge.id) ?.querySelector(".react-flow__edge-path"); if (!path) { // A geometrically touching edge is intentionally rendered without a path. continue; } const sourceHandle = Array.from( nodeElements .get(edge.source) ?.querySelectorAll(".port-handle[data-port-name]") ?? [], ).find((handle) => handle.dataset.portName === edge.sourceHandle); const targetHandle = Array.from( nodeElements .get(edge.target) ?.querySelectorAll(".port-handle[data-port-name]") ?? [], ).find((handle) => handle.dataset.portName === edge.targetHandle); const screenMatrix = path.getScreenCTM(); if (!sourceHandle || !targetHandle || !screenMatrix) { missingHandles.push(edge.id); continue; } const pathLength = path.getTotalLength(); const sourcePoint = path.getPointAtLength(0).matrixTransform(screenMatrix); const targetPoint = path .getPointAtLength(pathLength) .matrixTransform(screenMatrix); const sourceBounds = sourceHandle.getBoundingClientRect(); const targetBounds = targetHandle.getBoundingClientRect(); const sourceCenter = { x: sourceBounds.left + sourceBounds.width / 2, y: sourceBounds.top + sourceBounds.height / 2, }; const targetCenter = { x: targetBounds.left + targetBounds.width / 2, y: targetBounds.top + targetBounds.height / 2, }; measurements.push({ edgeId: edge.id, sourceDistance: Math.hypot( sourcePoint.x - sourceCenter.x, sourcePoint.y - sourceCenter.y, ), targetDistance: Math.hypot( targetPoint.x - targetCenter.x, targetPoint.y - targetCenter.y, ), }); } const worst = measurements .map((measurement) => ({ ...measurement, maxDistance: Math.max( measurement.sourceDistance, measurement.targetDistance, ), })) .sort((first, second) => second.maxDistance - first.maxDistance) .slice(0, 8); return { checkedEdgeCount: measurements.length, maxDistance: worst[0]?.maxDistance ?? Number.POSITIVE_INFINITY, missingHandles, worst, }; }, edges); } test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => { await prepareApp(page); await page.goto("/"); const viewport = page.locator(".flow-canvas .react-flow__viewport"); await expect(page.locator(".flow-canvas .react-flow__controls")).toHaveCount(0); await expect(page.locator(".flow-canvas .react-flow__attribution")).toHaveCount(0); const pane = page.locator(".flow-canvas .react-flow__pane"); const paneBox = await pane.boundingBox(); expect(paneBox).not.toBeNull(); await page.mouse.move( paneBox!.x + paneBox!.width / 2, paneBox!.y + paneBox!.height / 2, ); await page.keyboard.down("Control"); await page.mouse.wheel(0, 240); await page.keyboard.up("Control"); await page.waitForTimeout(250); const viewportBeforeDrop = await viewport.getAttribute("style"); expect(viewportBeforeDrop).toBeTruthy(); await page .getByRole("button", { name: "通用测试元件", exact: true }) .dragTo(page.locator(".flow-canvas .react-flow__pane")); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(1); await page.waitForTimeout(250); await expect(viewport).toHaveAttribute("style", viewportBeforeDrop!); await page.getByRole("button", { name: "适配视图", exact: true }).click(); await expect .poll(() => viewport.getAttribute("style")) .not.toBe(viewportBeforeDrop); }); test("普通滚轮纵向平移,Ctrl 滚轮缩放且滚动条和中键拖动保持同步", async ({ page, }) => { await prepareApp(page); await page.goto("/"); const canvas = page.locator(".flow-canvas"); const pane = canvas.locator(".react-flow__pane"); const horizontalScrollbar = page.getByRole("scrollbar", { name: "水平滚动画布", }); const verticalScrollbar = page.getByRole("scrollbar", { name: "垂直滚动画布", }); await expect(horizontalScrollbar).toBeVisible(); await expect(verticalScrollbar).toBeVisible(); const horizontalBox = await horizontalScrollbar.boundingBox(); const verticalBox = await verticalScrollbar.boundingBox(); expect(horizontalBox).not.toBeNull(); expect(verticalBox).not.toBeNull(); expect(horizontalBox!.height).toBeGreaterThanOrEqual(8); expect(horizontalBox!.height).toBeLessThanOrEqual(12); expect(verticalBox!.width).toBeGreaterThanOrEqual(8); expect(verticalBox!.width).toBeLessThanOrEqual(12); const paneBox = await pane.boundingBox(); expect(paneBox).not.toBeNull(); const pointer = { x: paneBox!.x + paneBox!.width * 0.55, y: paneBox!.y + paneBox!.height * 0.45, }; await page.mouse.move(pointer.x, pointer.y); const beforeWheel = await readModelingViewport(page); const beforeVerticalScroll = await verticalScrollbar.evaluate( (element) => element.scrollTop, ); await page.mouse.wheel(0, 180); await expect .poll(async () => (await readModelingViewport(page)).y) .not.toBeCloseTo(beforeWheel.y, 1); const afterWheel = await readModelingViewport(page); expect(afterWheel.x).toBeCloseTo(beforeWheel.x, 1); expect(afterWheel.zoom).toBeCloseTo(beforeWheel.zoom, 3); await expect .poll(() => verticalScrollbar.evaluate((element) => element.scrollTop)) .not.toBeCloseTo(beforeVerticalScroll, 1); const beforeZoom = await readModelingViewport(page); const horizontalBeforeZoom = await horizontalScrollbar.evaluate( (element) => element.scrollLeft, ); const verticalBeforeZoom = await verticalScrollbar.evaluate( (element) => element.scrollTop, ); await page.keyboard.down("Control"); await page.mouse.wheel(0, -180); await page.keyboard.up("Control"); await expect .poll(async () => (await readModelingViewport(page)).zoom) .not.toBeCloseTo(beforeZoom.zoom, 2); await expect.poll(async () => { const horizontalAfterZoom = await horizontalScrollbar.evaluate( (element) => element.scrollLeft, ); const verticalAfterZoom = await verticalScrollbar.evaluate( (element) => element.scrollTop, ); return Math.hypot( horizontalAfterZoom - horizontalBeforeZoom, verticalAfterZoom - verticalBeforeZoom, ); }).toBeGreaterThan(1); const beforeMiddleDrag = await readModelingViewport(page); await page.mouse.move(pointer.x, pointer.y); await page.mouse.down({ button: "middle" }); await page.mouse.move(pointer.x + 70, pointer.y + 45, { steps: 4 }); await page.mouse.up({ button: "middle" }); await expect .poll(async () => { const next = await readModelingViewport(page); return Math.hypot(next.x - beforeMiddleDrag.x, next.y - beforeMiddleDrag.y); }) .toBeGreaterThan(20); const afterMiddleDrag = await readModelingViewport(page); expect(afterMiddleDrag.zoom).toBeCloseTo(beforeMiddleDrag.zoom, 3); await expect(canvas.locator(".react-flow__selection")).toHaveCount(0); await verticalScrollbar.focus(); const beforeKeyboardScroll = await verticalScrollbar.evaluate( (element) => element.scrollTop, ); await page.keyboard.press("ArrowDown"); await expect .poll(() => verticalScrollbar.evaluate((element) => element.scrollTop)) .toBeGreaterThan(beforeKeyboardScroll); const beforeScrollbarDrag = await readModelingViewport(page); await horizontalScrollbar.evaluate((element) => { element.scrollLeft += 120; element.dispatchEvent(new Event("scroll", { bubbles: true })); }); await expect .poll(async () => (await readModelingViewport(page)).x) .not.toBeCloseTo(beforeScrollbarDrag.x, 1); const afterScrollbarDrag = await readModelingViewport(page); expect(afterScrollbarDrag.y).toBeCloseTo(beforeScrollbarDrag.y, 1); expect(afterScrollbarDrag.zoom).toBeCloseTo(beforeScrollbarDrag.zoom, 3); const horizontalMaximum = await horizontalScrollbar.evaluate( (element) => element.scrollWidth - element.clientWidth, ); const verticalMaximum = await verticalScrollbar.evaluate( (element) => element.scrollHeight - element.clientHeight, ); await expect(horizontalScrollbar).toHaveAttribute( "aria-valuemax", String(Math.round(horizontalMaximum)), ); await expect(verticalScrollbar).toHaveAttribute( "aria-valuemax", String(Math.round(verticalMaximum)), ); }); test("加载本地工程后自动适配建模画布", async ({ page }) => { await prepareApp(page); await page.addInitScript((project) => { window.localStorage.setItem( "system-simulation-flow:project:demo-system", JSON.stringify(project), ); }, wideProject); await page.goto("/"); await page.getByRole("button", { name: "加载工程" }).click(); await expectAllNodesInsideCanvas(page, ".flow-canvas"); const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true, }); await expect(consolePanel.locator(".simulation-console-dock-summary")).toContainText( "工程已加载", ); await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0); await page.getByRole("button", { name: "展开仿真控制台" }).click(); await expect(consolePanel).toContainText("工程已加载"); }); test("导入工程 JSON 后自动适配建模画布", async ({ page }) => { await prepareApp(page); await page.goto("/"); await page.locator('input[type="file"]').setInputFiles({ name: "wide-project.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(wideProject)), }); await expectAllNodesInsideCanvas(page, ".flow-canvas"); }); test("导入大型工程 JSON 后非接触连线端点与端口保持对齐", async ({ page, }) => { await prepareApp(page); await page.goto("/"); await page.locator('input[type="file"]').setInputFiles(MQL_8_PROJECT_PATH); await expectAllNodesInsideCanvas( page, ".flow-canvas", mql8Project.nodes.length, ); await expect .poll( async () => ( await readVisibleEdgeEndpointAlignment(page, mql8Project.edges) ).checkedEdgeCount, { timeout: 15_000 }, ) .toBeGreaterThan(20); await page.evaluate( () => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(() => resolve())); }), ); const alignment = await readVisibleEdgeEndpointAlignment( page, mql8Project.edges, ); expect(alignment.missingHandles).toEqual([]); expect(alignment.checkedEdgeCount).toBeGreaterThan(20); expect( alignment.maxDistance, `偏移最大的连线端点:${JSON.stringify(alignment.worst, null, 2)}`, // React Flow anchors to the outer edge of the 6 px port glyph rather // than its visual center; allow that radius plus subpixel rounding. ).toBeLessThanOrEqual(4); }); test("大型工程连续缩放时滚动条不逐帧重排且停稳后同步", async ({ page }) => { await prepareApp(page); await page.goto("/"); await page.locator('input[type="file"]').setInputFiles(MQL_8_PROJECT_PATH); await expectAllNodesInsideCanvas(page, ".flow-canvas", mql8Project.nodes.length); await expect(page.getByRole("scrollbar", { name: "水平滚动画布" })) .toHaveAttribute("aria-valuemax", /[1-9]/); const changes = await page.evaluate(async () => { const canvas = document.querySelector(".flow-canvas")!; const pane = canvas.querySelector(".react-flow__pane")!; const viewport = canvas.querySelector(".react-flow__viewport")!; const scrollbars = canvas.querySelector(".flow-viewport-scrollbars")!; const bounds = canvas.getBoundingClientRect(); let viewportChanges = 0; let scrollbarChanges = 0; const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.target === viewport) viewportChanges++; if (scrollbars.contains(mutation.target)) scrollbarChanges++; } }); observer.observe(viewport, { attributes: true, attributeFilter: ["style"] }); observer.observe(scrollbars, { attributes: true, attributeFilter: ["style"], subtree: true, }); // 连续输入覆盖整个手势,避免驱动侧等待导致其被拆成多个独立缩放。 for (let index = 0; index < 12; index++) { pane.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, ctrlKey: true, deltaY: -8, clientX: bounds.x + bounds.width / 2, clientY: bounds.y + bounds.height / 2, })); await new Promise((resolve) => requestAnimationFrame(() => resolve())); } observer.disconnect(); return { viewportChanges, scrollbarChanges }; }); expect(changes.viewportChanges).toBeGreaterThan(5); expect(changes.scrollbarChanges).toBeLessThan(changes.viewportChanges / 2); await expect.poll(async () => { const viewport = await readModelingViewport(page); const scale = await page.locator(".flow-canvas").evaluate((canvas) => Number(canvas.style.getPropertyValue("--port-handle-hit-scale")), ); return Math.abs(scale * viewport.zoom - 1); }).toBeLessThan(0.01); const alignment = await readVisibleEdgeEndpointAlignment(page, mql8Project.edges); expect(alignment.missingHandles).toEqual([]); expect(alignment.checkedEdgeCount).toBeGreaterThan(20); expect(alignment.maxDistance).toBeLessThanOrEqual(4); const zoom = (await readModelingViewport(page)).zoom; const horizontal = page.getByRole("scrollbar", { name: "水平滚动画布" }); await horizontal.focus(); const before = await readModelingViewport(page); await horizontal.press("ArrowRight"); await expect.poll(async () => (await readModelingViewport(page)).x).toBeLessThan(before.x); expect((await readModelingViewport(page)).zoom).toBeCloseTo(zoom, 5); }); test("恢复自动保存工程后自动适配建模画布", async ({ page }) => { await prepareApp(page); await page.addInitScript((project) => { window.localStorage.setItem( "system-simulation-flow:autosave", JSON.stringify(project), ); }, wideProject); await page.goto("/"); await page.getByRole("button", { name: "恢复" }).click(); await expectAllNodesInsideCanvas(page, ".flow-canvas"); const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true, }); await expect(consolePanel.locator(".simulation-console-dock-summary")).toContainText( "已恢复上次自动保存的工程", ); await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0); await page.getByRole("button", { name: "展开仿真控制台" }).click(); await expect(consolePanel).toContainText("已恢复上次自动保存的工程"); }); test("切换到结果页时自动适配只读系统图", async ({ page }) => { await prepareApp(page); await page.addInitScript(({ project, snapshot }) => { window.sessionStorage.setItem( "system-simulation-flow:latest-result", JSON.stringify(snapshot), ); window.localStorage.setItem( "system-simulation-flow:project:demo-system", JSON.stringify(project), ); window.localStorage.setItem( "system-simulation-flow:canvas-grid-visibility", JSON.stringify({ lines: true, dots: false }), ); }, { project: wideProject, snapshot: resultSnapshot }); await page.goto("/"); await page.getByRole("button", { name: "加载工程" }).click(); await expect( page.locator( '.flow-canvas .react-flow__background pattern[id$="major-grid-lines"]', ), ).toHaveCount(1); await expect( page.locator( '.flow-canvas .react-flow__background pattern[id$="minor-grid-dots"]', ), ).toHaveCount(0); const freeModelingHandle = page.locator( '.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-port-name="port_a"]', ); await freeModelingHandle.dispatchEvent("click"); await expect(freeModelingHandle).toHaveAttribute( "data-connection-state", "origin", ); await page.getByRole("tab", { name: /^结果/ }).click(); await expectAllNodesInsideCanvas(page, ".results-system-canvas"); await expect( page.locator( '.results-system-canvas .react-flow__background pattern[id$="major-grid-lines"]', ), ).toHaveCount(1); await expect( page.locator( '.results-system-canvas .react-flow__background pattern[id$="minor-grid-dots"]', ), ).toHaveCount(0); await expect( page.locator(".results-system-canvas .react-flow__controls"), ).toHaveCount(0); const fitSystemViewButton = page.getByRole("button", { name: "适应系统图窗口", exact: true, }); await expect(fitSystemViewButton).toBeVisible(); await expect( page.locator(".results-system-canvas .react-flow__attribution"), ).toHaveCount(0); await expect( page.getByRole("complementary", { name: "仿真控制台", exact: true }), ).toHaveCount(0); const resultPane = page.locator(".results-system-canvas .react-flow__pane"); const resultPaneBox = await resultPane.boundingBox(); expect(resultPaneBox).not.toBeNull(); const fittedResultViewport = await readViewport( page, ".results-system-canvas", ); await page.mouse.move( resultPaneBox!.x + resultPaneBox!.width / 2, resultPaneBox!.y + resultPaneBox!.height / 2, ); await page.mouse.down(); await page.mouse.move( resultPaneBox!.x + resultPaneBox!.width / 2 + 180, resultPaneBox!.y + resultPaneBox!.height / 2 + 90, { steps: 5 }, ); await page.mouse.up(); await expect .poll(async () => { const viewport = await readViewport(page, ".results-system-canvas"); return Math.hypot( viewport.x - fittedResultViewport.x, viewport.y - fittedResultViewport.y, ); }) .toBeGreaterThan(100); const pannedResultViewport = await readViewport( page, ".results-system-canvas", ); await fitSystemViewButton.click(); await expect .poll(async () => { const viewport = await readViewport(page, ".results-system-canvas"); return Math.hypot( viewport.x - pannedResultViewport.x, viewport.y - pannedResultViewport.y, ); }) .toBeGreaterThan(100); await expectAllNodesInsideCanvas(page, ".results-system-canvas"); await page.getByRole("tab", { name: "建模" }).click(); await expectAllNodesInsideCanvas(page, ".flow-canvas"); await expect(freeModelingHandle).toHaveAttribute( "data-connection-state", "idle", ); await expect( page.getByRole("complementary", { name: "仿真控制台", exact: true }), ).toBeVisible(); await page.getByRole("tab", { name: /^结果/ }).click(); await expectAllNodesInsideCanvas(page, ".results-system-canvas"); await expect( page.getByRole("complementary", { name: "仿真控制台", exact: true }), ).toHaveCount(0); await expect(page.locator(".results-system-canvas .react-flow__edge")).toHaveCount( wideProject.edges.length, ); const connectedResultHandles = page.locator( '.results-system-canvas .port-handle[data-connected="true"]', ); const idleResultHandles = page.locator( '.results-system-canvas .port-handle[data-connected="false"]', ); await expect(connectedResultHandles).toHaveCount( wideProject.edges.length * 2, ); await expect(idleResultHandles).toHaveCount(2); await expect(connectedResultHandles.first()).toHaveCSS("opacity", "0"); await expect( idleResultHandles.locator('[data-port-glyph-shape="circle"]'), ).toHaveCount(2); const resultViewportZoom = await page .locator(".results-system-canvas .react-flow__viewport") .evaluate( (element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).a, ); const idleResultPortShapes = await idleResultHandles.evaluateAll((elements) => elements.map((element) => { const bounds = element.getBoundingClientRect(); const glyphBounds = element .querySelector(".port-handle-glyph") ?.getBoundingClientRect(); const circle = element.querySelector( '[data-port-glyph-shape="circle"]', ); return { circle: circle ? { cx: circle.cx.baseVal.value, cy: circle.cy.baseVal.value, r: circle.r.baseVal.value, } : null, glyphHeight: glyphBounds?.height ?? null, glyphWidth: glyphBounds?.width ?? null, height: bounds.height, width: bounds.width, }; }), ); for (const shape of idleResultPortShapes) { expect(Math.abs(shape.width - 6)).toBeLessThan(0.05); expect(Math.abs(shape.height - 6)).toBeLessThan(0.05); expect(shape.glyphWidth).not.toBeNull(); expect(shape.glyphHeight).not.toBeNull(); expect( Math.abs(shape.glyphWidth! - 6 * resultViewportZoom), ).toBeLessThan(0.1); expect( Math.abs(shape.glyphHeight! - 6 * resultViewportZoom), ).toBeLessThan(0.1); expect(Math.abs(shape.glyphWidth! - shape.glyphHeight!)).toBeLessThan(0.01); expect(shape.circle).toEqual({ cx: 3, cy: 3, r: 2.5 }); } }); test("结果页系统图为真实大型工程的非连接交叉线显示线桥", async ({ page, }) => { await prepareApp(page); await page.addInitScript((snapshot) => { window.sessionStorage.setItem( "system-simulation-flow:latest-result", JSON.stringify(snapshot), ); }, mql8ResultSnapshot); await page.goto("/"); await page.getByRole("tab", { name: "结果", exact: true }).click(); await expect( page.locator(".results-system-canvas .react-flow__node"), ).toHaveCount(mql8Project.nodes.length); await expect( page.locator( '.results-system-canvas .react-flow__edge[data-id="edge-amesim_p4node2_1-port_2-amesim_pnl0001_13-port_2-1786972847258"] .react-flow__edge-path', ), ).toHaveAttribute("d", / Q /); }); test("从结果页返回建模页后保留用户调整的建模视口", async ({ page }) => { await prepareApp(page); await page.addInitScript(({ project, snapshot }) => { window.sessionStorage.setItem( "system-simulation-flow:latest-result", JSON.stringify(snapshot), ); window.localStorage.setItem( "system-simulation-flow:project:demo-system", JSON.stringify(project), ); }, { project: wideProject, snapshot: resultSnapshot }); await page.goto("/"); await page.getByRole("button", { name: "加载工程" }).click(); await expectAllNodesInsideCanvas(page, ".flow-canvas"); const modelingPane = page.locator(".flow-canvas .react-flow__pane"); const modelingPaneBox = await modelingPane.boundingBox(); expect(modelingPaneBox).not.toBeNull(); const fittedViewport = await readModelingViewport(page); const pointer = { x: modelingPaneBox!.x + modelingPaneBox!.width * 0.55, y: modelingPaneBox!.y + modelingPaneBox!.height * 0.45, }; await page.mouse.move(pointer.x, pointer.y); await page.mouse.down({ button: "middle" }); await page.mouse.move(pointer.x + 180, pointer.y + 90, { steps: 5 }); await page.mouse.up({ button: "middle" }); await expect .poll(async () => { const viewport = await readModelingViewport(page); return Math.hypot( viewport.x - fittedViewport.x, viewport.y - fittedViewport.y, ); }) .toBeGreaterThan(100); const beforeZoom = await readModelingViewport(page); await page.keyboard.down("Control"); await page.mouse.wheel(0, -180); await page.keyboard.up("Control"); await expect .poll(async () => (await readModelingViewport(page)).zoom) .not.toBeCloseTo(beforeZoom.zoom, 2); const adjustedViewport = await readModelingViewport(page); await page.getByRole("tab", { name: /^结果/ }).click(); await expectAllNodesInsideCanvas(page, ".results-system-canvas"); await page.getByRole("tab", { name: "建模" }).click(); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount( wideProject.nodes.length, ); await page.waitForTimeout(250); const restoredViewport = await readModelingViewport(page); expect( Math.hypot( restoredViewport.x - adjustedViewport.x, restoredViewport.y - adjustedViewport.y, ), ).toBeLessThan(2); expect(restoredViewport.zoom).toBeCloseTo(adjustedViewport.zoom, 3); }); test("结果页专用图标只按实际包络命中被相邻节点包围的元件", async ({ page, }) => { await prepareApp(page); const pneumaticPort = (name: string, side: "left" | "right") => ({ name, kind: "physical", domain: "pneumatic", nominalRole: "bidirectional", positiveFlowDirection: "intoComponent", side, }); const pnplNode = ( id: string, position: { x: number; y: number }, rotation: 0 | 90 | 180 | 270, ) => ({ id, type: "simulationComponent", position, data: { label: id, componentType: "amesim_pnpl01", modelType: "amesim_pnpl01", modelVersion: "0.1.0", ports: [pneumaticPort("port_1", "left")], parameters: {}, parameterUnits: {}, parameterScientificNotation: {}, rotation, mirrored: false, }, }); const targetNodeId = "amesim_pnch012_2"; const overlapProject = { ...wideProject, name: "overlapped-symbols", nodes: [ { id: "generic_sensor_0", type: "simulationComponent", position: { x: 360, y: 390 }, data: { label: "generic_sensor_0", componentType: "generic_sensor", modelType: "generic_sensor", modelVersion: "1.0.0", symbol: "symbol-not-registered", ports: [], parameters: {}, parameterUnits: {}, parameterScientificNotation: {}, rotation: 0, mirrored: false, }, }, { id: targetNodeId, type: "simulationComponent", position: { x: 569.2129060235693, y: 515.6448805401301 }, data: { label: targetNodeId, componentType: "amesim_pnch012", modelType: "amesim_pnch012", modelVersion: "0.1.0", ports: [ pneumaticPort("port_1", "left"), pneumaticPort("port_2", "right"), pneumaticPort("port_3", "left"), pneumaticPort("port_4", "right"), ], parameters: {}, parameterUnits: {}, parameterScientificNotation: {}, rotation: 0, mirrored: false, }, }, pnplNode( "amesim_pnpl01_7", { x: 535.6129060235693, y: 523.6448805401301 }, 180, ), pnplNode( "amesim_pnpl01_8", { x: 587.2129060235693, y: 555.2448805401301 }, 90, ), pnplNode( "amesim_pnpl01_9", { x: 618.8129060235693, y: 523.6448805401301 }, 0, ), ], edges: [], }; const overlapSnapshot = { ...resultSnapshot, id: "overlapped-symbols-result", project: overlapProject, }; await page.addInitScript((snapshot) => { window.sessionStorage.setItem( "system-simulation-flow:latest-result", JSON.stringify(snapshot), ); }, overlapSnapshot); await page.goto("/"); await page.getByRole("tab", { name: /^结果/ }).click(); await expectAllNodesInsideCanvas( page, ".results-system-canvas", overlapProject.nodes.length, ); const targetNode = page.locator( `.results-system-canvas .react-flow__node[data-id="${targetNodeId}"]`, ); const targetEnvelope = targetNode.locator('[data-symbol-envelope="true"]'); await expect(targetNode).toHaveClass(/component-icon-node/); await expect(targetNode).toHaveCSS("pointer-events", "none"); await expect(targetEnvelope).toHaveCSS("pointer-events", "auto"); const pane = page.locator(".results-system-canvas .react-flow__pane"); const paneBox = await pane.boundingBox(); expect(paneBox).not.toBeNull(); await page.mouse.click(paneBox!.x + 6, paneBox!.y + 6); await expect(targetNode).not.toHaveClass(/selected/); const envelopeBox = await targetEnvelope.boundingBox(); expect(envelopeBox).not.toBeNull(); const targetPoint = { x: envelopeBox!.x + envelopeBox!.width / 2, y: envelopeBox!.y + envelopeBox!.height / 2, }; const hitNodeId = await page.evaluate(({ x, y }) => { const element = document.elementFromPoint(x, y); return element?.closest(".react-flow__node")?.dataset.id ?? null; }, targetPoint); expect(hitNodeId).toBe(targetNodeId); await page.mouse.click(targetPoint.x, targetPoint.y); await expect(targetNode).toHaveClass(/selected/); await expect( page.locator( '.results-system-canvas .react-flow__node[data-id^="amesim_pnpl01_"].selected', ), ).toHaveCount(0); });