import { expect, test, type Locator, type Page } from "@playwright/test"; type SymbolCase = { category: string; domain: "medium" | "pneumatic" | "signal" | "mechanical"; modelType: string; }; const symbolCases: SymbolCase[] = [ { category: "media", domain: "medium", modelType: "amesim_ideal_air_medium" }, { category: "media", domain: "medium", modelType: "amesim_helium_medium" }, { category: "storage", domain: "pneumatic", modelType: "amesim_pnch023" }, { category: "storage", domain: "pneumatic", modelType: "amesim_pnch012" }, { category: "flow", domain: "pneumatic", modelType: "amesim_pnor001" }, { category: "flow", domain: "pneumatic", modelType: "amesim_pnvo001_fixed" }, { category: "flow", domain: "pneumatic", modelType: "amesim_pnvo001" }, { category: "flow", domain: "pneumatic", modelType: "amesim_pnl00r" }, { category: "flow", domain: "pneumatic", modelType: "amesim_pnl0001" }, { category: "flow", domain: "pneumatic", modelType: "amesim_pnl0002" }, { category: "flow", domain: "pneumatic", modelType: "amesim_pnl0003" }, { category: "junctions", domain: "pneumatic", modelType: "amesim_pn3node2" }, { category: "junctions", domain: "pneumatic", modelType: "amesim_p4node2" }, { category: "boundary", domain: "pneumatic", modelType: "amesim_pnpl01" }, { category: "signals", domain: "signal", modelType: "amesim_step0" }, { category: "signals", domain: "signal", modelType: "amesim_ud00" }, { category: "mechanical", domain: "mechanical", modelType: "amesim_f000" }, { category: "mechanical", domain: "mechanical", modelType: "amesim_forc" }, { category: "mechanical", domain: "mechanical", modelType: "amesim_mecmas21" }, { category: "mechanical", domain: "mechanical", modelType: "amesim_lstp00a" }, { category: "mechanical", domain: "mechanical", modelType: "amesim_lmechn1" }, { category: "mechanical", domain: "mechanical", modelType: "amesim_pnrp17" }, ]; function physicalPort( name: string, side: "left" | "right", order: number, ) { return { name, kind: "physical", domain: "pneumatic", nominalRole: "bidirectional", positiveFlowDirection: "intoComponent", side, order, }; } function signalPort( name: string, side: "left" | "right", nominalRole: "input" | "output", order: number, ) { return { name, kind: "signal", domain: "signal", nominalRole, side, order, }; } function mechanicalPort( name: string, side: "left" | "right", order: number, ) { return { name, kind: "physical", domain: "mechanical", nominalRole: "bidirectional", positiveFlowDirection: "intoComponent", side, order, }; } function portsForSymbol(modelType: string) { if (modelType === "amesim_pnor001") { return [ physicalPort("port_1", "left", 10), physicalPort("port_2", "right", 20), ]; } if (modelType === "amesim_pnvo001") { return [ signalPort("res", "left", "input", 5), physicalPort("port_2", "left", 10), physicalPort("port_3", "right", 20), ]; } if (modelType === "amesim_step0") { return [signalPort("out", "right", "output", 10)]; } if (modelType === "amesim_mecmas21") { return [ mechanicalPort("port_2", "left", 10), mechanicalPort("port_1", "right", 20), ]; } if (modelType === "amesim_lmechn1") { return [ ...Array.from({ length: 8 }, (_, index) => mechanicalPort(`port_${index + 1}`, "left", (index + 1) * 10), ), mechanicalPort("port_9", "right", 90), ]; } if (modelType === "amesim_pnrp17") { return [ physicalPort("port_1", "left", 10), mechanicalPort("port_3", "left", 20), mechanicalPort("port_4", "left", 30), mechanicalPort("port_2", "right", 40), mechanicalPort("port_5", "right", 50), ]; } return []; } async function dragSymbolToCanvas( page: Page, modelType: string, targetPosition: { x: number; y: number }, ) { const pane = page.locator(".flow-canvas .react-flow__pane"); const paletteItem = page .locator(".palette-item") .filter({ has: page.locator(`[data-component-symbol="${modelType}"]`), }) .first(); await paletteItem.dragTo(pane, { targetPosition }); const node = page .locator( `.flow-canvas .react-flow__node[data-id^="${modelType}_"]`, ) .last(); await expect(node).toBeVisible(); const nodeId = await node.getAttribute("data-id"); expect(nodeId).not.toBeNull(); return page.locator( `.flow-canvas .react-flow__node[data-id="${nodeId}"]`, ); } async function expectSymbolAnchors( node: Locator, anchors: Array<{ portName: string; x: number; y: number }>, ) { for (const anchor of anchors) { const error = await node.evaluate((element, expected) => { const svg = element.querySelector(".component-symbol-svg"); const handle = element.querySelector( `.port-handle[data-port-name="${expected.portName}"]`, ); const matrix = svg?.getScreenCTM(); if (!svg || !handle || !matrix) { return Number.POSITIVE_INFINITY; } const point = svg.createSVGPoint(); point.x = expected.x; point.y = expected.y; const screenPoint = point.matrixTransform(matrix); const handleBounds = handle.getBoundingClientRect(); return Math.hypot( screenPoint.x - (handleBounds.left + handleBounds.width / 2), screenPoint.y - (handleBounds.top + handleBounds.height / 2), ); }, anchor); expect(error).toBeLessThan(2.5); } } async function expectPortIndexLabels( node: Locator, expected: Record, ) { const labels = node.locator(".port-index-label"); await expect(labels).toHaveCount(Object.keys(expected).length); for (const [portName, text] of Object.entries(expected)) { const label = node.locator( `.port-index-label[data-port-name="${portName}"]`, ); await expect(label).toHaveCount(1); await expect(label).toHaveText(text); await expect(label).toHaveAttribute("data-port-index", text); } const firstLabel = labels.first(); await expect(firstLabel).toHaveCSS("font-size", "18px"); await expect(firstLabel).toHaveCSS("background-color", "rgb(255, 255, 255)"); await expect(firstLabel).toHaveCSS("border-top-style", "none"); await expect(firstLabel).toHaveCSS("box-shadow", "none"); await expect(firstLabel).toHaveCSS("pointer-events", "none"); } async function portIndexLabelLayout(node: Locator) { return node.evaluate((element) => { const labels = Array.from( element.querySelectorAll(".port-index-label"), ).map((label) => { const portName = label.dataset.portName ?? ""; const handle = element.querySelector( `.port-handle[data-port-name="${portName}"]`, ); const bounds = label.getBoundingClientRect(); const handleBounds = handle?.getBoundingClientRect(); const labelCenter = { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2, }; const handleCenter = handleBounds ? { x: handleBounds.left + handleBounds.width / 2, y: handleBounds.top + handleBounds.height / 2, } : null; const outward = handle && handleCenter ? handle.classList.contains("react-flow__handle-left") ? labelCenter.x < handleCenter.x : handle.classList.contains("react-flow__handle-right") ? labelCenter.x > handleCenter.x : handle.classList.contains("react-flow__handle-top") ? labelCenter.y < handleCenter.y : labelCenter.y > handleCenter.y : false; return { bounds: { bottom: bounds.bottom, left: bounds.left, right: bounds.right, top: bounds.top, }, distance: handleBounds ? Math.hypot( labelCenter.x - handleCenter!.x, labelCenter.y - handleCenter!.y, ) : Number.POSITIVE_INFINITY, outward, }; }); let overlaps = 0; for (let firstIndex = 0; firstIndex < labels.length; firstIndex += 1) { for ( let secondIndex = firstIndex + 1; secondIndex < labels.length; secondIndex += 1 ) { const first = labels[firstIndex].bounds; const second = labels[secondIndex].bounds; if ( Math.min(first.right, second.right) > Math.max(first.left, second.left) && Math.min(first.bottom, second.bottom) > Math.max(first.top, second.top) ) { overlaps += 1; } } } return { maximumDistance: Math.max(...labels.map((label) => label.distance)), minimumDistance: Math.min(...labels.map((label) => label.distance)), overlaps, outwardViolations: labels.filter((label) => !label.outward).length, }; }); } async function locatorCenter(locator: Locator) { const bounds = await locator.boundingBox(); expect(bounds).not.toBeNull(); return { x: bounds!.x + bounds!.width / 2, y: bounds!.y + bounds!.height / 2, }; } async function distanceBetween(first: Locator, second: Locator) { const [firstCenter, secondCenter] = await Promise.all([ locatorCenter(first), locatorCenter(second), ]); return Math.hypot( secondCenter.x - firstCenter.x, secondCenter.y - firstCenter.y, ); } async function dragNodePortNearTarget( page: Page, movingNode: Locator, movingPort: Locator, targetPort: Locator, remainingGap = 8, ) { const [dragStart, movingPortCenter, targetPortCenter] = await Promise.all([ locatorCenter(movingNode.locator(".node-symbol")), locatorCenter(movingPort), locatorCenter(targetPort), ]); const deltaX = targetPortCenter.x - movingPortCenter.x; const deltaY = targetPortCenter.y - movingPortCenter.y; const distance = Math.hypot(deltaX, deltaY); expect(distance).toBeGreaterThan(remainingGap); const movementRatio = (distance - remainingGap) / distance; await page.mouse.move(dragStart.x, dragStart.y); await page.mouse.down(); let pointerX = dragStart.x + deltaX * movementRatio; let pointerY = dragStart.y + deltaY * movementRatio; await page.mouse.move(pointerX, pointerY, { steps: 12 }); // 旋转会改变节点外框尺寸,React Flow 可能同时刷新 viewport;根据实际 // Handle 中心再校正两次,确保测试模拟的是用户真正把端口拖到附近。 for (let attempt = 0; attempt < 2; attempt += 1) { const [actualMovingCenter, actualTargetCenter] = await Promise.all([ locatorCenter(movingPort), locatorCenter(targetPort), ]); const correctionX = actualTargetCenter.x - actualMovingCenter.x; const correctionY = actualTargetCenter.y - actualMovingCenter.y; const actualDistance = Math.hypot(correctionX, correctionY); if (actualDistance <= remainingGap + 1) { break; } const correctionRatio = (actualDistance - remainingGap) / actualDistance; pointerX += correctionX * correctionRatio; pointerY += correctionY * correctionRatio; await page.mouse.move(pointerX, pointerY, { steps: 4 }); } } const categoryLabels: Record = { boundary: "边界元件", flow: "流动元件", junctions: "连接元件", mechanical: "机械元件", media: "介质物性", signals: "信号元件", storage: "储能元件", }; test.beforeEach(async ({ page }) => { await page.addInitScript(() => { const preparedKey = "system-simulation-flow:component-symbols-prepared"; if (window.sessionStorage.getItem(preparedKey) !== "1") { window.localStorage.clear(); window.sessionStorage.clear(); window.sessionStorage.setItem(preparedKey, "1"); } window.location.hash = "#/modeling"; }); await page.route("**/api/components/catalog", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ schemaVersion: 1, libraries: [ { id: "amesim", label: "AMESim 组件库", version: "0.2.0", sourcePackage: "tests.e2e.amesim-symbols", temporary: true, order: 1, components: symbolCases.map((item, index) => ({ type: item.modelType, label: item.modelType, modelType: item.modelType, modelVersion: "0.1.0", symbol: item.modelType, order: index + 1, category: { id: item.category, label: categoryLabels[item.category], order: Object.keys(categoryLabels).indexOf(item.category) + 1, }, ports: portsForSymbol(item.modelType), parameters: item.modelType === "amesim_mecmas21" ? [ { name: "useFriction", label: "启用摩擦", quantity: "dimensionless", unit: "", default: 1, minimum: 1, maximum: 2, minimumExclusive: false, editor: "choice", options: [ { value: 1, label: "否" }, { value: 2, label: "是" }, ], }, { name: "stoptype", label: "限位类型", quantity: "dimensionless", unit: "", default: 4, minimum: 1, maximum: 4, minimumExclusive: false, editor: "choice", options: [ { value: 1, label: "理想限位" }, { value: 2, label: "弹性限位" }, { value: 3, label: "恢复碰撞" }, { value: 4, label: "无限位" }, ], }, ] : [], })), }, ], }), }); }); }); test("全部已移植 AMESim 模型使用专属图标及正确领域配色", async ({ page }) => { await page.goto("/"); const palette = page.locator("aside.palette"); await expect(palette.locator('[data-component-symbol^="amesim_"]')).toHaveCount( symbolCases.length, ); await expect(palette.locator('[data-component-symbol="generic"]')).toHaveCount(0); for (const item of symbolCases) { const symbol = palette.locator(`[data-component-symbol="${item.modelType}"]`); await expect(symbol).toHaveCount(1); await expect(symbol.locator("svg")).toHaveAttribute("data-symbol-domain", item.domain); await expect(symbol).toHaveAttribute( "data-symbol-presentation", item.domain === "medium" ? "bare" : "framed", ); } }); test("MECMAS21 图标按摩擦和限位参数切换四种参考外观", async ({ page }) => { await page.goto("/"); const node = await dragSymbolToCanvas(page, "amesim_mecmas21", { x: 430, y: 300, }); const symbol = node.locator( '[data-component-symbol="amesim_mecmas21"]', ); const state = symbol.locator("[data-mecmas-variant]"); const core = symbol.locator('[data-mecmas-part="core"]'); const envelope = node.locator('[data-symbol-envelope="true"]'); const useFriction = page.getByLabel("启用摩擦", { exact: true }); const stoptype = page.getByLabel("限位类型", { exact: true }); const envelopeHeight = async () => (await envelope.boundingBox())?.height ?? 0; await expectSymbolAnchors(node, [ { portName: "port_2", x: 2, y: 24 }, { portName: "port_1", x: 62, y: 24 }, ]); const expectVariant = async ({ friction, guides, limits, lowerGuide, variant, }: { friction: "disabled" | "enabled"; guides: boolean; limits: "finite" | "unlimited"; lowerGuide: "finite" | "none" | "unlimited"; variant: string; }) => { await expect(state).toHaveAttribute("data-mecmas-variant", variant); await expect(state).toHaveAttribute("data-mecmas-friction", friction); await expect(state).toHaveAttribute("data-mecmas-limits", limits); await expect( symbol.locator('[data-mecmas-part="friction-markers"]'), ).toHaveCount(friction === "enabled" ? 1 : 0); await expect(symbol.locator('[data-mecmas-part="guides"]')).toHaveCount( guides ? 1 : 0, ); await expect( symbol.locator('[data-mecmas-part="finite-lower-guide"]'), ).toHaveCount(lowerGuide === "finite" ? 1 : 0); await expect( symbol.locator('[data-mecmas-part="unlimited-lower-guide"]'), ).toHaveCount(lowerGuide === "unlimited" ? 1 : 0); await expect(symbol.locator("svg")).toHaveAttribute("viewBox", "0 0 64 48"); }; await expectVariant({ friction: "disabled", guides: false, limits: "unlimited", lowerGuide: "none", variant: "plain-unlimited", }); await expect.poll(envelopeHeight).toBeLessThan(60); const fixedCore = await core.evaluate((element: SVGGElement) => { const bounds = element.getBBox(); return { bounds: { height: bounds.height, width: bounds.width, x: bounds.x, y: bounds.y, }, markup: element.innerHTML, }; }); const compactEnvelopeHeight = await envelopeHeight(); await useFriction.selectOption("2"); await expectVariant({ friction: "enabled", guides: true, limits: "unlimited", lowerGuide: "unlimited", variant: "friction-unlimited", }); await expect.poll(envelopeHeight).toBeGreaterThan(compactEnvelopeHeight + 20); await stoptype.selectOption("2"); await expectVariant({ friction: "enabled", guides: true, limits: "finite", lowerGuide: "finite", variant: "friction-limited", }); await useFriction.selectOption("1"); await expectVariant({ friction: "disabled", guides: true, limits: "finite", lowerGuide: "finite", variant: "plain-limited", }); await stoptype.selectOption("4"); await expectVariant({ friction: "disabled", guides: false, limits: "unlimited", lowerGuide: "none", variant: "plain-unlimited", }); await expect.poll(envelopeHeight).toBeLessThan(compactEnvelopeHeight + 1); expect(await envelopeHeight()).toBeCloseTo(compactEnvelopeHeight, 1); expect( await core.evaluate((element: SVGGElement) => { const bounds = element.getBBox(); return { bounds: { height: bounds.height, width: bounds.width, x: bounds.x, y: bounds.y, }, markup: element.innerHTML, }; }), ).toEqual(fixedCore); }); test("PNRP17 图标匹配参考结构并将五个端口放在对应接口", async ({ page }) => { await page.goto("/"); const paletteSymbol = page.locator( 'aside.palette [data-component-symbol="amesim_pnrp17"]', ); await expect(paletteSymbol).toHaveCount(1); await expect( paletteSymbol.locator('[stroke="#00af00"], [fill="#00af00"]'), ).toHaveCount(0); const piston = await dragSymbolToCanvas(page, "amesim_pnrp17", { x: 430, y: 300, }); await expectSymbolAnchors(piston, [ { portName: "port_1", x: 25.5, y: 48 }, { portName: "port_3", x: 2, y: 4.4 }, { portName: "port_4", x: 2, y: 24.1 }, { portName: "port_2", x: 62, y: 4.4 }, { portName: "port_5", x: 62, y: 24.1 }, ]); await expect( piston.locator('.port-handle[data-port-name="port_1"]'), ).toHaveClass(/react-flow__handle-bottom/); }); test("选中元件时按接口编号显示固定且不重叠的数字标记", async ({ page, }) => { await page.goto("/"); const mecmas = await dragSymbolToCanvas(page, "amesim_mecmas21", { x: 260, y: 220, }); await expectPortIndexLabels(mecmas, { port_1: "1", port_2: "2" }); let layout = await portIndexLabelLayout(mecmas); expect(layout.overlaps).toBe(0); expect(layout.outwardViolations).toBe(0); expect(layout.minimumDistance).toBeGreaterThan(18); expect(layout.maximumDistance).toBeLessThan(52); const signalOrifice = await dragSymbolToCanvas(page, "amesim_pnvo001", { x: 520, y: 220, }); await expect(mecmas.locator(".port-index-label")).toHaveCount(0); await expectPortIndexLabels(signalOrifice, { res: "1", port_2: "2", port_3: "3", }); layout = await portIndexLabelLayout(signalOrifice); expect(layout.overlaps).toBe(0); expect(layout.outwardViolations).toBe(0); expect(layout.maximumDistance).toBeLessThan(52); const multiPortNode = await dragSymbolToCanvas(page, "amesim_lmechn1", { x: 780, y: 300, }); await expect(signalOrifice.locator(".port-index-label")).toHaveCount(0); await expectPortIndexLabels( multiPortNode, Object.fromEntries( Array.from({ length: 9 }, (_, index) => [ `port_${index + 1}`, String(index + 1), ]), ), ); layout = await portIndexLabelLayout(multiPortNode); expect(layout.overlaps).toBe(0); expect(layout.outwardViolations).toBe(0); expect(layout.minimumDistance).toBeGreaterThan(18); expect(layout.maximumDistance).toBeLessThan(52); await page .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) .click(); await page .getByRole("button", { name: "水平镜像 (Shift+H)", exact: true }) .click(); await expectPortIndexLabels( multiPortNode, Object.fromEntries( Array.from({ length: 9 }, (_, index) => [ `port_${index + 1}`, String(index + 1), ]), ), ); layout = await portIndexLabelLayout(multiPortNode); expect(layout.overlaps).toBe(0); expect(layout.outwardViolations).toBe(0); expect(layout.maximumDistance).toBeLessThan(52); const outsideLabelBounds = await multiPortNode .locator('.port-index-label[data-port-name="port_9"]') .boundingBox(); expect(outsideLabelBounds).not.toBeNull(); await page.mouse.click( outsideLabelBounds!.x + outsideLabelBounds!.width / 2, outsideLabelBounds!.y + outsideLabelBounds!.height / 2, ); await expect(multiPortNode).not.toHaveClass(/selected/); await expect(multiPortNode.locator(".port-index-label")).toHaveCount(0); }); test("AMESim canvas nodes use icon anchors and highlight only compatible free ports", async ({ page, }) => { await page.goto("/"); const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", { x: 250, y: 210, }); const secondOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", { x: 610, y: 210, }); const stepSource = await dragSymbolToCanvas(page, "amesim_step0", { x: 250, y: 450, }); const signalOrifice = await dragSymbolToCanvas(page, "amesim_pnvo001", { x: 610, y: 450, }); await expect(firstOrifice.locator(".sim-node")).toHaveClass( /component-icon-only/, ); await expect(firstOrifice.locator(".node-label")).toBeHidden(); const frameStyle = await firstOrifice.locator(".sim-node-frame").evaluate( (element) => ({ borderWidth: getComputedStyle(element).borderTopWidth, background: getComputedStyle(element).backgroundColor, }), ); expect(frameStyle).toEqual({ borderWidth: "0px", background: "rgba(0, 0, 0, 0)", }); const firstOrificeAnchors = [ { portName: "port_1", x: 2, y: 24 }, { portName: "port_2", x: 62, y: 24 }, ]; await expectSymbolAnchors(firstOrifice, firstOrificeAnchors); await expectSymbolAnchors(signalOrifice, [ { portName: "res", x: 26, y: 46 }, { portName: "port_2", x: 2, y: 24 }, { portName: "port_3", x: 62, y: 24 }, ]); await firstOrifice.locator(".node-symbol").click({ position: { x: 56, y: 42 } }); await page .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) .click(); await expectSymbolAnchors(firstOrifice, firstOrificeAnchors); await page .getByRole("button", { name: "水平镜像 (Shift+H)", exact: true }) .click(); await expectSymbolAnchors(firstOrifice, firstOrificeAnchors); const firstPhysicalOutput = firstOrifice.locator( '.port-handle[data-port-name="port_2"]', ); const sameNodePort = firstOrifice.locator( '.port-handle[data-port-name="port_1"]', ); const compatiblePhysicalPort = secondOrifice.locator( '.port-handle[data-port-name="port_1"]', ); const signalOutput = stepSource.locator( '.port-handle[data-port-name="out"]', ); const signalInput = signalOrifice.locator( '.port-handle[data-port-name="res"]', ); await expect(signalInput).toHaveClass(/react-flow__handle-bottom/); const pneumaticInput = signalOrifice.locator( '.port-handle[data-port-name="port_2"]', ); await firstPhysicalOutput.click(); await expect(firstPhysicalOutput).toHaveAttribute( "data-connection-state", "origin", ); await expect(compatiblePhysicalPort).toHaveAttribute( "data-connection-state", "compatible", ); await expect(pneumaticInput).toHaveAttribute( "data-connection-state", "compatible", ); await expect(sameNodePort).toHaveAttribute( "data-connection-state", "incompatible", ); await expect(signalInput).toHaveAttribute( "data-connection-state", "incompatible", ); await expect(sameNodePort).toHaveCSS("pointer-events", "none"); await expect(signalInput).toHaveCSS("pointer-events", "none"); await page.locator(".flow-canvas .react-flow__pane").click({ position: { x: 20, y: 20 }, }); await expect(firstPhysicalOutput).toHaveAttribute( "data-connection-state", "idle", ); await compatiblePhysicalPort.click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); await expect(compatiblePhysicalPort).toHaveAttribute( "data-connection-state", "origin", ); await page.keyboard.press("Escape"); await expect(compatiblePhysicalPort).toHaveAttribute( "data-connection-state", "idle", ); await firstPhysicalOutput.click(); await compatiblePhysicalPort.click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true"); await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true"); await expect(firstPhysicalOutput).toHaveCSS("opacity", "0"); await expect(compatiblePhysicalPort).toHaveCSS("opacity", "0"); await expect(firstPhysicalOutput).toHaveCSS("pointer-events", "none"); await expect(compatiblePhysicalPort).toHaveCSS("pointer-events", "none"); const physicalEdge = page.locator(".flow-canvas .react-flow__edge").first(); const edgeClickPoint = await physicalEdge .locator(".react-flow__edge-interaction") .evaluate((path: SVGPathElement) => { const point = path.getPointAtLength(path.getTotalLength() / 2); const matrix = path.getScreenCTM(); if (!matrix) { throw new Error("Unable to resolve the edge screen position"); } const screenPoint = new DOMPoint(point.x, point.y).matrixTransform(matrix); return { x: screenPoint.x, y: screenPoint.y }; }); await page.mouse.click(edgeClickPoint.x, edgeClickPoint.y); await expect(physicalEdge).toHaveClass(/selected|editor-edge-selected/); await page.getByRole("button", { name: "删除 (Delete)", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "false"); await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "false"); await expect(firstPhysicalOutput).toHaveCSS("opacity", "1"); await expect(compatiblePhysicalPort).toHaveCSS("opacity", "1"); await expect(firstPhysicalOutput).not.toHaveCSS("pointer-events", "none"); await expect(compatiblePhysicalPort).not.toHaveCSS("pointer-events", "none"); await firstPhysicalOutput.click(); await compatiblePhysicalPort.click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); const signalOutputBounds = await signalOutput.boundingBox(); const signalInputBounds = await signalInput.boundingBox(); expect(signalOutputBounds).not.toBeNull(); expect(signalInputBounds).not.toBeNull(); await page.mouse.move( signalOutputBounds!.x + signalOutputBounds!.width / 2, signalOutputBounds!.y + signalOutputBounds!.height / 2, ); await page.mouse.down(); await expect(signalOutput).toHaveAttribute( "data-connection-state", "origin", ); await expect(signalInput).toHaveAttribute( "data-connection-state", "compatible", ); const paneBounds = await page .locator(".flow-canvas .react-flow__pane") .boundingBox(); expect(paneBounds).not.toBeNull(); await page.mouse.move(paneBounds!.x + 20, paneBounds!.y + 20, { steps: 4 }); await page.mouse.up(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await expect(page.locator(".flow-canvas .react-flow__connection")).toHaveCount(0); await expect(signalOutput).toHaveAttribute( "data-connection-state", "idle", ); await expect(signalInput).toHaveAttribute( "data-connection-state", "idle", ); await expect(pneumaticInput).toHaveAttribute( "data-connection-state", "idle", ); const retriedSignalOutputBounds = await signalOutput.boundingBox(); const retriedSignalInputBounds = await signalInput.boundingBox(); expect(retriedSignalOutputBounds).not.toBeNull(); expect(retriedSignalInputBounds).not.toBeNull(); await page.mouse.move( retriedSignalOutputBounds!.x + retriedSignalOutputBounds!.width / 2, retriedSignalOutputBounds!.y + retriedSignalOutputBounds!.height / 2, ); await page.mouse.down(); await expect(signalOutput).toHaveAttribute( "data-connection-state", "origin", ); await page.mouse.move( retriedSignalOutputBounds!.x + retriedSignalOutputBounds!.width / 2 + 12, retriedSignalOutputBounds!.y + retriedSignalOutputBounds!.height / 2, ); await expect(signalInput).toHaveAttribute( "data-connection-state", "compatible", ); await expect(pneumaticInput).toHaveAttribute( "data-connection-state", "incompatible", ); await page.mouse.move( retriedSignalInputBounds!.x + retriedSignalInputBounds!.width / 2, retriedSignalInputBounds!.y + retriedSignalInputBounds!.height / 2, { steps: 5 }, ); await page.mouse.up(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2); await expect(signalOutput).toHaveAttribute("data-connected", "true"); await expect(signalInput).toHaveAttribute("data-connected", "true"); }); test("兼容接口接近时吸附为无连线接触连接,拖开后自动断开", async ({ page, }) => { await page.goto("/"); await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click(); const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", { x: 280, y: 300, }); const secondOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", { x: 650, y: 300, }); const firstPort = firstOrifice.locator( '.port-handle[data-port-name="port_2"]', ); const secondPort = secondOrifice.locator( '.port-handle[data-port-name="port_1"]', ); await dragNodePortNearTarget( page, secondOrifice, secondPort, firstPort, 16, ); await expect(firstPort).toHaveAttribute("data-connection-state", "idle"); await expect(secondPort).toHaveAttribute("data-connection-state", "idle"); expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(14); await page.mouse.up(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); await dragNodePortNearTarget( page, secondOrifice, secondPort, firstPort, ); await expect(firstPort).toHaveAttribute( "data-connection-state", "compatible", ); await expect(secondPort).toHaveAttribute( "data-connection-state", "origin", ); expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(4); await page.mouse.up(); await expect .poll(() => distanceBetween(firstPort, secondPort)) .toBeLessThan(2); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0); const contactHit = page.locator(".flow-canvas .contact-edge-hit"); await expect(contactHit).toHaveCount(1); await expect(firstPort).toHaveAttribute("data-connected", "true"); await expect(secondPort).toHaveAttribute("data-connected", "true"); await expect(firstPort).toHaveCSS("opacity", "0"); await expect(secondPort).toHaveCSS("opacity", "0"); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); const xmlBlock = page.getByLabel("生成的系统 XML"); await expect(xmlBlock).toContainText(' { const raw = window.localStorage.getItem( "system-simulation-flow:project:demo-system", ); const project = raw ? JSON.parse(raw) : null; return project?.edges?.[0] ?? null; }); expect(savedEdge).toMatchObject({ source: "amesim_pnor001_2", sourceHandle: "port_1", target: "amesim_pnor001_1", targetHandle: "port_2", }); await contactHit.click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveClass( /editor-edge-selected/, ); await page.getByRole("button", { name: "删除 (Delete)", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); await expect(firstPort).toHaveAttribute("data-connected", "false"); await expect(secondPort).toHaveAttribute("data-connected", "false"); expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(8); await dragNodePortNearTarget( page, secondOrifice, secondPort, firstPort, 5, ); await expect(firstPort).toHaveAttribute( "data-connection-state", "compatible", ); await page.mouse.up(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0); await page .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) .click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); await page.getByRole("button", { name: "撤销 (Ctrl+Z)", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0); const secondSymbolCenter = await locatorCenter( secondOrifice.locator(".node-symbol"), ); await page.mouse.move(secondSymbolCenter.x, secondSymbolCenter.y); await page.mouse.down(); await page.mouse.move(secondSymbolCenter.x + 90, secondSymbolCenter.y + 35, { steps: 10, }); await page.mouse.up(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); await expect(firstPort).toHaveAttribute("data-connected", "false"); await expect(secondPort).toHaveAttribute("data-connected", "false"); await expect(firstPort).toHaveCSS("opacity", "1"); await expect(secondPort).toHaveCSS("opacity", "1"); expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(50); }); test("不同类型接口接近时不会吸附或建立逻辑连接", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click(); const orifice = await dragSymbolToCanvas(page, "amesim_pnor001", { x: 280, y: 320, }); const stepSource = await dragSymbolToCanvas(page, "amesim_step0", { x: 650, y: 320, }); const physicalPort = orifice.locator( '.port-handle[data-port-name="port_2"]', ); const signalPort = stepSource.locator( '.port-handle[data-port-name="out"]', ); await dragNodePortNearTarget( page, stepSource, signalPort, physicalPort, ); await expect(physicalPort).not.toHaveAttribute( "data-connection-state", "compatible", ); await page.mouse.up(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); expect(await distanceBetween(physicalPort, signalPort)).toBeGreaterThan(4); await expect(physicalPort).toHaveAttribute("data-connected", "false"); await expect(signalPort).toHaveAttribute("data-connected", "false"); }); test("旋转后的接口可以与方向不同的兼容接口接触连接", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click(); const horizontalOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", { x: 300, y: 300, }); const rotatedOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", { x: 650, y: 500, }); await rotatedOrifice.locator(".node-symbol").click(); await page .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) .click(); const horizontalPort = horizontalOrifice.locator( '.port-handle[data-port-name="port_2"]', ); const rotatedPort = rotatedOrifice.locator( '.port-handle[data-port-name="port_1"]', ); await dragNodePortNearTarget( page, rotatedOrifice, rotatedPort, horizontalPort, ); await expect(horizontalPort).toHaveAttribute( "data-connection-state", "compatible", ); await expect(rotatedPort).toHaveAttribute( "data-connection-state", "origin", ); await page.mouse.up(); await expect .poll(() => distanceBetween(horizontalPort, rotatedPort)) .toBeLessThan(2); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0); await expect(horizontalPort).toHaveAttribute("data-connected", "true"); await expect(rotatedPort).toHaveAttribute("data-connected", "true"); }); test("旧工程保存的通用图标会按当前目录定义升级", async ({ page }) => { await page.goto("/"); await page.evaluate(() => { window.localStorage.setItem( "system-simulation-flow:autosave", JSON.stringify({ name: "legacy-symbol-project", nodes: [ { id: "amesim_pnl0001_1", type: "simulationComponent", position: { x: 200, y: 160 }, data: { label: "amesim_pnl0001_1", componentType: "amesim_pnl0001", modelType: "amesim_pnl0001", symbol: "pipe", ports: [], parameters: {}, parameterUnits: {}, rotation: 0, mirrored: false, }, }, ], edges: [], simulation: { t_start: 0, t_stop: 1, step: 0.1, max_step: 0.05, method: "BDF", }, }), ); }); await page.reload(); await page.getByRole("button", { name: "恢复", exact: true }).click(); const restoredNode = page.locator( '.flow-canvas .react-flow__node[data-id="amesim_pnl0001_1"]', ); await expect(restoredNode).toBeVisible(); await expect( restoredNode.locator('[data-component-symbol="amesim_pnl0001"]'), ).toBeVisible(); await expect(restoredNode.locator('[data-component-symbol="pipe"]')).toHaveCount(0); }); test("专用图标的选择框和鼠标命中范围贴合实际图形包络", async ({ page }) => { await page.goto("/"); const helium = await dragSymbolToCanvas(page, "amesim_helium_medium", { x: 430, y: 280, }); const simNode = helium.locator(".sim-node"); const envelope = helium.locator('[data-symbol-envelope="true"]'); const circle = helium.locator(".component-symbol-svg circle"); await expect(helium).toHaveClass(/component-icon-node/); await expect(helium).toHaveCSS("pointer-events", "none"); await expect(simNode).toHaveCSS("pointer-events", "none"); await expect(envelope).toHaveCSS("outline-style", "dashed"); await expect(envelope).toHaveCSS("pointer-events", "auto"); await expect(simNode).toHaveCSS("outline-style", "none"); const [nodeBounds, envelopeBounds, circleBounds, paneBounds] = await Promise.all([ helium.boundingBox(), envelope.boundingBox(), circle.boundingBox(), page.locator(".flow-canvas .react-flow__pane").boundingBox(), ]); expect(nodeBounds).not.toBeNull(); expect(envelopeBounds).not.toBeNull(); expect(circleBounds).not.toBeNull(); expect(paneBounds).not.toBeNull(); expect(envelopeBounds!.width).toBeLessThan(nodeBounds!.width * 0.7); expect( Math.abs(envelopeBounds!.width - envelopeBounds!.height), ).toBeLessThan(3); expect(envelopeBounds!.width - circleBounds!.width).toBeGreaterThanOrEqual(0); expect(envelopeBounds!.width - circleBounds!.width).toBeLessThanOrEqual(4); await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8); await expect(helium).not.toHaveClass(/selected/); const oldTransparentMargin = { x: nodeBounds!.x + 5, y: envelopeBounds!.y + envelopeBounds!.height / 2, }; expect(oldTransparentMargin.x).toBeLessThan(envelopeBounds!.x - 8); await page.mouse.click(oldTransparentMargin.x, oldTransparentMargin.y); await expect(helium).not.toHaveClass(/selected/); await page.mouse.click( envelopeBounds!.x + envelopeBounds!.width / 2, envelopeBounds!.y + envelopeBounds!.height / 2, ); await expect(helium).toHaveClass(/selected/); await expect(envelope).toHaveCSS("outline-style", "dashed"); });