import { expect, test, type Page } from "@playwright/test"; import { expectAllNodesInsideCanvas, prepareApp, resultSnapshot, wideProject, } from "./fixtures"; async function readModelingViewport(page: Page) { return page .locator(".flow-canvas .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, }; }); } 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); const horizontalAfterZoom = await horizontalScrollbar.evaluate( (element) => element.scrollLeft, ); const verticalAfterZoom = await verticalScrollbar.evaluate( (element) => element.scrollTop, ); expect( 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("恢复自动保存工程后自动适配建模画布", 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), ); }, { project: wideProject, snapshot: resultSnapshot }); await page.goto("/"); await page.getByRole("button", { name: "加载工程" }).click(); 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__attribution"), ).toHaveCount(0); await expect( page.getByRole("complementary", { name: "仿真控制台", exact: true }), ).toHaveCount(0); 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 }); } });