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_pnpl01") { return [physicalPort("port_1", "left", 10)]; } if (modelType === "amesim_pnch023") { return [ physicalPort("port_1", "left", 10), physicalPort("port_2", "right", 20), ]; } if (modelType === "amesim_pnch012") { return [ physicalPort("port_1", "left", 10), physicalPort("port_2", "right", 20), physicalPort("port_3", "left", 30), physicalPort("port_4", "right", 40), ]; } if (modelType === "amesim_pnor001") { return [ physicalPort("port_1", "left", 10), physicalPort("port_2", "right", 20), ]; } if (modelType === "amesim_pnvo001_fixed") { return [ physicalPort("port_2", "left", 10), physicalPort("port_3", "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" || modelType === "amesim_ud00") { return [signalPort("out", "right", "output", 10)]; } if (modelType === "amesim_f000") { return [mechanicalPort("port_1", "right", 10)]; } if (modelType === "amesim_forc") { return [ signalPort("res", "left", "input", 10), mechanicalPort("port_2", "right", 20), ]; } 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 = 4, ) { 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); const svg = symbol.locator("svg"); await expect(svg).toHaveAttribute("data-symbol-domain", item.domain); await expect(svg).toHaveAttribute( "data-symbol-stroke-scaling", "independent", ); await expect(symbol).toHaveAttribute( "data-symbol-presentation", item.domain === "medium" ? "bare" : "framed", ); const registeredViewBox = await symbol.getAttribute("data-symbol-view-box"); expect(registeredViewBox).not.toBeNull(); await expect(svg).toHaveAttribute( "viewBox", registeredViewBox!, ); const strokeScaling = await svg.evaluate((element) => { const graphics = Array.from( element.querySelectorAll( "path, line, polyline, polygon, rect, circle, ellipse, text", ), ); const strokedGraphics = graphics.filter( (graphic) => getComputedStyle(graphic).stroke !== "none", ); return { count: strokedGraphics.length, independent: strokedGraphics.every( (graphic) => getComputedStyle(graphic).vectorEffect === "non-scaling-stroke", ), }; }); expect(strokeScaling.count).toBeGreaterThan(0); expect(strokeScaling.independent).toBe(true); } }); test("组件库按尺寸档位显示不同缩略图占比且不影响工作区", async ({ page }) => { await page.goto("/"); const paletteCases = [ { modelType: "amesim_pnpl01", occupancy: 0.45, tier: "compact" }, { modelType: "amesim_f000", occupancy: 0.45, tier: "compact" }, { modelType: "amesim_pnch023", occupancy: 0.75, tier: "small" }, { modelType: "amesim_pnch012", occupancy: 0.75, tier: "small" }, { modelType: "amesim_pnrp17", occupancy: 0.9, tier: "standard" }, { modelType: "amesim_lmechn1", occupancy: 0.9, tier: "custom" }, ] as const; for (const item of paletteCases) { const symbol = page.locator( `aside.palette [data-component-symbol="${item.modelType}"]`, ); await expect(symbol).toHaveAttribute("data-symbol-size-tier", item.tier); await expect(symbol).toHaveAttribute( "data-palette-occupancy", String(item.occupancy), ); const geometry = await symbol .locator("svg") .evaluate((element: SVGSVGElement) => { const bounds = element.getBBox(); const viewBox = element.viewBox.baseVal; return { centerOffsetX: bounds.x + bounds.width / 2 - (viewBox.x + viewBox.width / 2), centerOffsetY: bounds.y + bounds.height / 2 - (viewBox.y + viewBox.height / 2), occupancy: Math.max(bounds.width, bounds.height) / Math.max(viewBox.width, viewBox.height), }; }); expect(geometry.occupancy).toBeCloseTo(item.occupancy, 3); expect(geometry.centerOffsetX).toBeCloseTo(0, 3); expect(geometry.centerOffsetY).toBeCloseTo(0, 3); } const compactOccupancies = await page .locator('aside.palette [data-symbol-size-tier="compact"]') .evaluateAll((elements) => elements.map((element) => { const svg = element.querySelector("svg") as SVGSVGElement | null; if (!svg) { return 0; } const bounds = svg.getBBox(); const viewBox = svg.viewBox.baseVal; return ( Math.max(bounds.width, bounds.height) / Math.max(viewBox.width, viewBox.height) ); }), ); expect(compactOccupancies.length).toBeGreaterThan(1); for (const occupancy of compactOccupancies) { expect(occupancy).toBeCloseTo(0.45, 3); } const canvasSymbol = await dragSymbolToCanvas(page, "amesim_pnrp17", { x: 430, y: 280, }); const canvasRatio = await canvasSymbol .locator('[data-component-symbol="amesim_pnrp17"]') .evaluate((element) => { const symbolBounds = element.getBoundingClientRect(); const slotBounds = element.parentElement!.getBoundingClientRect(); return { height: symbolBounds.height / slotBounds.height, width: symbolBounds.width / slotBounds.width, }; }); await expect( canvasSymbol.locator('[data-component-symbol="amesim_pnrp17"]'), ).not.toHaveAttribute("data-palette-occupancy", /.+/); expect(canvasRatio.width).toBeCloseTo(1, 2); expect(canvasRatio.height).toBeCloseTo(1, 2); }); test("介质物性组件统一使用 standard 画布、60% 图标占比和洋红文字", async ({ page, }) => { await page.goto("/"); const cases = [ { label: "Air", modelType: "amesim_ideal_air_medium", target: { x: 300, y: 260 }, }, { label: "He", modelType: "amesim_helium_medium", target: { x: 560, y: 260 }, }, ] as const; for (const item of cases) { const paletteSymbol = page.locator( `aside.palette [data-component-symbol="${item.modelType}"]`, ); await expect(paletteSymbol).toHaveAttribute( "data-symbol-size-tier", "standard", ); const node = await dragSymbolToCanvas(page, item.modelType, item.target); const simNode = node.locator(".sim-node"); const symbol = node.locator( `[data-component-symbol="${item.modelType}"]`, ); const svg = symbol.locator("svg"); const circle = svg.locator("circle"); const label = svg.locator(`[data-symbol-label="${item.label}"]`); await expect(simNode).toHaveAttribute("data-node-layout-tier", "standard"); await expect(simNode).toHaveAttribute("data-node-layout-width", "112"); await expect(simNode).toHaveAttribute("data-node-layout-height", "112"); await expect(symbol).toHaveAttribute("data-symbol-width", "112"); await expect(symbol).toHaveAttribute("data-symbol-height", "112"); await expect(svg).toHaveAttribute("viewBox", "0 -8 64 64"); await expect(circle).toHaveAttribute("r", "19.2"); await expect(circle).toHaveAttribute("stroke", "#8b134f"); await expect(circle).toHaveCSS("stroke", "rgb(139, 19, 79)"); await expect(label).toHaveAttribute("font-size", "13"); await expect(label).toHaveAttribute("font-weight", "600"); await expect(label).toHaveAttribute("fill", "#8b134f"); await expect(label).toHaveCSS("font-size", "13px"); await expect(label).toHaveCSS("font-weight", "600"); await expect(label).toHaveCSS("fill", "rgb(139, 19, 79)"); const occupancy = await svg.evaluate((element: SVGSVGElement) => { const outline = element.querySelector("circle"); if (!outline) { return 0; } const bounds = outline.getBBox(); const viewBox = element.viewBox.baseVal; return Math.max(bounds.width, bounds.height) / Math.max(viewBox.width, viewBox.height); }); expect(occupancy).toBeCloseTo(0.6, 4); } }); test("PNOR001 与两种 PNVO001 使用统一孔板风格和 small 画布", async ({ page, }) => { await page.goto("/"); const paletteFlowPath = page.locator( 'aside.palette [data-component-symbol="amesim_pnvo001_fixed"] [data-orifice-part="flow-path"]', ); await expect(paletteFlowPath).toHaveCSS("stroke-width", "1.5px"); await expect(paletteFlowPath).toHaveCSS( "vector-effect", "non-scaling-stroke", ); const cases = [ { anchors: [ { portName: "port_1", x: 12.8, y: 24 }, { portName: "port_2", x: 51.2, y: 24 }, ], control: "none", modelType: "amesim_pnor001", target: { x: 220, y: 250 }, }, { anchors: [ { portName: "port_2", x: 12.8, y: 24 }, { portName: "port_3", x: 51.2, y: 24 }, ], control: "fixed", modelType: "amesim_pnvo001_fixed", target: { x: 460, y: 250 }, }, { anchors: [ { portName: "res", x: 27.35, y: 43.2 }, { portName: "port_2", x: 12.8, y: 24 }, { portName: "port_3", x: 51.2, y: 24 }, ], control: "signal", modelType: "amesim_pnvo001", target: { x: 700, y: 250 }, }, ] as const; for (const item of cases) { const node = await dragSymbolToCanvas(page, item.modelType, item.target); const simNode = node.locator(".sim-node"); const symbol = node.locator( `[data-component-symbol="${item.modelType}"]`, ); const family = symbol.locator('[data-orifice-family="pnvo001"]'); const flowPath = family.locator('[data-orifice-part="flow-path"]'); const curves = family.locator('[data-orifice-part="curves"]'); const openingArrow = family.locator( '[data-orifice-part="opening-arrow"]', ); const signalControl = family.locator( '[data-orifice-part="signal-control"]', ); await expect(simNode).toHaveAttribute("data-node-layout-tier", "small"); const nodeBounds = await node.boundingBox(); expect(nodeBounds).not.toBeNull(); expect(Math.abs(nodeBounds!.width - 116)).toBeLessThan(1); expect(Math.abs(nodeBounds!.height - 96)).toBeLessThan(1); await expect(family).toHaveAttribute("data-orifice-control", item.control); await expect(family).toHaveAttribute("transform", /scale\(0\.6\)/); await expect(flowPath).toHaveAttribute("stroke-width", "1.5"); await expect(flowPath).toHaveAttribute("stroke", "#8b134f"); await expect(flowPath).toHaveCSS("stroke-width", "1.5px"); await expect(flowPath).toHaveCSS("vector-effect", "non-scaling-stroke"); await expect(curves).toHaveAttribute("stroke-width", "2.5"); await expect(curves).toHaveCSS("stroke-width", "2.5px"); await expect(curves).toHaveCSS("vector-effect", "non-scaling-stroke"); await expect(openingArrow).toHaveCount(item.control === "none" ? 0 : 1); if (item.control !== "none") { await expect(openingArrow).toHaveAttribute("stroke-width", "1.5"); await expect(openingArrow).toHaveCSS("stroke-width", "1.5px"); await expect(openingArrow).toHaveCSS( "vector-effect", "non-scaling-stroke", ); } await expect(signalControl).toHaveCount(item.control === "signal" ? 1 : 0); if (item.control === "signal") { await expect(signalControl).toHaveAttribute("stroke-width", "1.5"); await expect(signalControl).toHaveCSS("stroke-width", "1.5px"); await expect(signalControl).toHaveCSS( "vector-effect", "non-scaling-stroke", ); } const [familyBounds, symbolBounds, envelopeBounds] = await Promise.all([ family.boundingBox(), symbol.boundingBox(), node.locator('[data-symbol-envelope="true"]').boundingBox(), ]); expect(familyBounds).not.toBeNull(); expect(symbolBounds).not.toBeNull(); expect(envelopeBounds).not.toBeNull(); expect(familyBounds!.width / symbolBounds!.width).toBeGreaterThan(0.58); expect(familyBounds!.width / symbolBounds!.width).toBeLessThan(0.63); expect(envelopeBounds!.x).toBeLessThanOrEqual(familyBounds!.x); expect(envelopeBounds!.y).toBeLessThanOrEqual(familyBounds!.y); expect(envelopeBounds!.x + envelopeBounds!.width).toBeGreaterThanOrEqual( familyBounds!.x + familyBounds!.width, ); expect(envelopeBounds!.y + envelopeBounds!.height).toBeGreaterThanOrEqual( familyBounds!.y + familyBounds!.height, ); expect(envelopeBounds!.width - familyBounds!.width).toBeGreaterThan(3); expect(envelopeBounds!.width - familyBounds!.width).toBeLessThan(9); expect(envelopeBounds!.height - familyBounds!.height).toBeGreaterThan(3); expect(envelopeBounds!.height - familyBounds!.height).toBeLessThan(9); if (item.control !== "none") { const aspectRatio = familyBounds!.width / familyBounds!.height; expect(aspectRatio).toBeGreaterThan(0.95); expect(aspectRatio).toBeLessThan(1.05); const envelopeAspectRatio = envelopeBounds!.width / envelopeBounds!.height; expect(envelopeAspectRatio).toBeGreaterThan(0.95); expect(envelopeAspectRatio).toBeLessThan(1.05); } await expectSymbolAnchors(node, [...item.anchors]); } }); test("PNPL01 保持 compact 25% 叉形主体并延长接口引线", async ({ page, }) => { await page.goto("/"); const node = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 300, y: 240, }); const simNode = node.locator(".sim-node"); const symbol = node.locator('[data-component-symbol="amesim_pnpl01"]'); const svg = symbol.locator("svg"); const body = svg.locator('[data-pnpl01-part="body"]'); const extension = svg.locator('[data-pnpl01-part="extension"]'); const cross = svg.locator('[data-pnpl01-part="cross"]'); const handle = node.locator('.port-handle[data-port-name="port_1"]'); await expect(simNode).toHaveAttribute("data-node-layout-tier", "compact"); await expect(body).toHaveAttribute("transform", /scale\(0\.25\)/); await expect(extension).toHaveAttribute("stroke-width", "1.5"); await expect(extension).toHaveAttribute("stroke", "#8b134f"); await expect(extension).toHaveCSS("stroke-width", "1.5px"); await expect(extension).toHaveAttribute("d", "M16 24H35.75"); await expect(cross).toHaveAttribute("stroke-width", "4"); await expect(cross).toHaveAttribute("stroke", "#8b134f"); await expect(cross).toHaveCSS("stroke-width", "4px"); await expect(extension).toHaveCSS("vector-effect", "non-scaling-stroke"); await expect(cross).toHaveCSS("vector-effect", "non-scaling-stroke"); await expectSymbolAnchors(node, [{ portName: "port_1", x: 16, y: 24 }]); const [nodeBounds, contentGeometry] = await Promise.all([ node.boundingBox(), body.evaluate((element: SVGGElement) => { const bounds = element.getBBox(); const matrix = element.transform.baseVal.consolidate()?.matrix; return { height: bounds.height, scale: matrix?.a ?? 1, width: bounds.width, }; }), ]); expect(nodeBounds).not.toBeNull(); expect(Math.abs(nodeBounds!.width - 100)).toBeLessThan(1); expect(Math.abs(nodeBounds!.height - 80)).toBeLessThan(1); expect(contentGeometry.width).toBeCloseTo(34, 4); expect(contentGeometry.height).toBeCloseTo(34, 4); expect(contentGeometry.scale).toBeCloseTo(0.25, 4); const [ crossBounds, envelopeBounds, symbolBounds, extensionBounds, handleBounds, ] = await Promise.all([ cross.boundingBox(), node.locator('[data-symbol-envelope="true"]').boundingBox(), symbol.boundingBox(), extension.boundingBox(), handle.boundingBox(), ]); expect(crossBounds).not.toBeNull(); expect(envelopeBounds).not.toBeNull(); expect(symbolBounds).not.toBeNull(); expect(extensionBounds).not.toBeNull(); expect(handleBounds).not.toBeNull(); expect(extensionBounds!.x + extensionBounds!.width).toBeGreaterThan( handleBounds!.x + handleBounds!.width + 12, ); expect(Math.abs(crossBounds!.width - crossBounds!.height)).toBeLessThan(0.5); const crossCenter = { x: crossBounds!.x + crossBounds!.width / 2, y: crossBounds!.y + crossBounds!.height / 2, }; expect(crossCenter.x).toBeGreaterThan(envelopeBounds!.x); expect(crossCenter.x).toBeLessThan( envelopeBounds!.x + envelopeBounds!.width, ); expect(crossCenter.y).toBeGreaterThan(envelopeBounds!.y); expect(crossCenter.y).toBeLessThan( envelopeBounds!.y + envelopeBounds!.height, ); expect(envelopeBounds!.width / symbolBounds!.width).toBeLessThan(0.5); expect(envelopeBounds!.height / symbolBounds!.height).toBeLessThan(0.5); }); test("F000 保持 compact 25% 方框主体并延长接口引线", async ({ page }) => { await page.goto("/"); const node = await dragSymbolToCanvas(page, "amesim_f000", { x: 300, y: 240, }); const simNode = node.locator(".sim-node"); const symbol = node.locator('[data-component-symbol="amesim_f000"]'); const body = symbol.locator('[data-f000-part="body"]'); const frame = symbol.locator('[data-f000-part="frame"]'); const extension = symbol.locator('[data-f000-part="extension"]'); const handle = node.locator('.port-handle[data-port-name="port_1"]'); await expect(simNode).toHaveAttribute("data-node-layout-tier", "compact"); await expect(body).toHaveAttribute("transform", /scale\(0\.25\)/); await expect(frame).toHaveAttribute("stroke-width", "2.5"); await expect(frame).toHaveCSS("stroke-width", "2.5px"); await expect(extension).toHaveAttribute("stroke-width", "1.5"); await expect(extension).toHaveCSS("stroke-width", "1.5px"); await expect(extension).toHaveAttribute("d", "M37 24H48"); await expect(frame).toHaveCSS("vector-effect", "non-scaling-stroke"); await expect(extension).toHaveCSS("vector-effect", "non-scaling-stroke"); await expectSymbolAnchors(node, [{ portName: "port_1", x: 48, y: 24 }]); const [nodeBounds, geometry] = await Promise.all([ node.boundingBox(), body.evaluate((element: SVGGElement) => { const bounds = element.getBBox(); const matrix = element.transform.baseVal.consolidate()?.matrix; return { height: bounds.height, scale: matrix?.a ?? 1, width: bounds.width, }; }), ]); expect(nodeBounds).not.toBeNull(); expect(Math.abs(nodeBounds!.width - 100)).toBeLessThan(1); expect(Math.abs(nodeBounds!.height - 80)).toBeLessThan(1); expect(geometry.width).toBeCloseTo(52, 4); expect(geometry.height).toBeCloseTo(34, 4); expect(geometry.scale).toBeCloseTo(0.25, 4); const [extensionBounds, handleBounds] = await Promise.all([ extension.boundingBox(), handle.boundingBox(), ]); expect(extensionBounds).not.toBeNull(); expect(handleBounds).not.toBeNull(); expect(handleBounds!.x - extensionBounds!.x).toBeGreaterThan(6); }); test("STEP0 与 UD00 使用 standard 画布并在工作区占比 60%", async ({ page, }) => { await page.goto("/"); const cases = [ { modelType: "amesim_step0", source: "step0", x: 320 }, { modelType: "amesim_ud00", source: "ud00", x: 560 }, ] as const; for (const item of cases) { const node = await dragSymbolToCanvas(page, item.modelType, { x: item.x, y: 260, }); const simNode = node.locator(".sim-node"); const symbol = node.locator( `[data-component-symbol="${item.modelType}"]`, ); const content = symbol.locator(`[data-signal-source="${item.source}"]`); await expect(simNode).toHaveAttribute("data-node-layout-tier", "standard"); await expect(content).toHaveAttribute("transform", /scale\(0\.75294/); await expectSymbolAnchors(node, [{ portName: "out", x: 51.2, y: 24 }]); const [nodeBounds, symbolBounds, contentGeometry] = await Promise.all([ node.boundingBox(), symbol.boundingBox(), content.evaluate((element: SVGGElement) => { const bounds = element.getBBox(); const matrix = element.transform.baseVal.consolidate()?.matrix; return { height: bounds.height, scale: matrix?.a ?? 1, width: bounds.width, }; }), ]); expect(nodeBounds).not.toBeNull(); expect(symbolBounds).not.toBeNull(); expect(Math.abs(nodeBounds!.width - 132)).toBeLessThan(1); expect(Math.abs(nodeBounds!.height - 112)).toBeLessThan(1); expect(contentGeometry.width).toBeCloseTo(51, 4); expect(contentGeometry.height).toBeCloseTo(36, 4); expect( (contentGeometry.width * contentGeometry.scale) / 64, ).toBeCloseTo(0.6, 4); const contentBounds = await content.boundingBox(); expect(contentBounds).not.toBeNull(); const visibleOccupancy = contentBounds!.width / symbolBounds!.width; expect(visibleOccupancy).toBeGreaterThan(0.6); // 独立描边不会随主体缩放,含描边的可见包络会略大于 60%。 expect(visibleOccupancy).toBeLessThan(0.63); } }); test("FORC 按参考图使用 standard 画布并在工作区占比 60%", async ({ page, }) => { await page.goto("/"); const node = await dragSymbolToCanvas(page, "amesim_forc", { x: 420, y: 280, }); const simNode = node.locator(".sim-node"); const symbol = node.locator('[data-component-symbol="amesim_forc"]'); const content = symbol.locator('[data-forc-part="symbol"]'); const thickParts = symbol.locator( '[data-forc-part="input-arrow"], [data-forc-part="force-circle"], [data-forc-part="mechanical-socket"]', ); const thinParts = symbol.locator( '[data-forc-part="left-extension"], [data-forc-part="right-extension"]', ); const label = symbol.locator('[data-forc-part="label"]'); const circle = symbol.locator('[data-forc-part="force-circle"]'); const leftExtension = symbol.locator('[data-forc-part="left-extension"]'); const rightExtension = symbol.locator('[data-forc-part="right-extension"]'); const leftHandle = node.locator('.port-handle[data-port-name="res"]'); const rightHandle = node.locator('.port-handle[data-port-name="port_2"]'); await expect(simNode).toHaveAttribute("data-node-layout-tier", "standard"); await expect(content).toHaveAttribute("transform", /scale\(0\.64\)/); await expect(thickParts).toHaveCount(3); for (let index = 0; index < (await thickParts.count()); index += 1) { await expect(thickParts.nth(index)).toHaveCSS("stroke-width", "2px"); await expect(thickParts.nth(index)).toHaveCSS( "vector-effect", "non-scaling-stroke", ); } await expect(thinParts).toHaveCount(2); for (let index = 0; index < (await thinParts.count()); index += 1) { await expect(thinParts.nth(index)).toHaveCSS("stroke-width", "1.5px"); await expect(thinParts.nth(index)).toHaveCSS( "vector-effect", "non-scaling-stroke", ); } await expect(label).toHaveText("F"); await expect(label).toHaveAttribute("font-size", "15"); await expect(label).toHaveCSS("font-weight", "600"); await expectSymbolAnchors(node, [ { portName: "res", x: 12.8, y: 24 }, { portName: "port_2", x: 51.2, y: 24 }, ]); const [ nodeBounds, symbolBounds, contentGeometry, circleBounds, labelBounds, leftExtensionBounds, rightExtensionBounds, leftHandleBounds, rightHandleBounds, ] = await Promise.all([ node.boundingBox(), symbol.boundingBox(), content.evaluate((element: SVGGElement) => { const bounds = element.getBBox(); const matrix = element.transform.baseVal.consolidate()?.matrix; return { height: bounds.height, scale: matrix?.a ?? 1, width: bounds.width, }; }), circle.boundingBox(), label.boundingBox(), leftExtension.boundingBox(), rightExtension.boundingBox(), leftHandle.boundingBox(), rightHandle.boundingBox(), ]); expect(nodeBounds).not.toBeNull(); expect(symbolBounds).not.toBeNull(); expect(circleBounds).not.toBeNull(); expect(labelBounds).not.toBeNull(); expect(leftExtensionBounds).not.toBeNull(); expect(rightExtensionBounds).not.toBeNull(); expect(leftHandleBounds).not.toBeNull(); expect(rightHandleBounds).not.toBeNull(); expect(Math.abs(nodeBounds!.width - 132)).toBeLessThan(1); expect(Math.abs(nodeBounds!.height - 112)).toBeLessThan(1); expect(contentGeometry.width).toBeCloseTo(60, 4); expect(contentGeometry.scale).toBeCloseTo(0.64, 4); expect( (contentGeometry.width * contentGeometry.scale) / 64, ).toBeCloseTo(0.6, 4); expect(labelBounds!.height / circleBounds!.height).toBeGreaterThan(0.58); expect(labelBounds!.height / circleBounds!.height).toBeLessThan(0.68); expect(leftExtensionBounds!.x + leftExtensionBounds!.width).toBeGreaterThan( leftHandleBounds!.x + leftHandleBounds!.width + 8, ); expect(rightHandleBounds!.x - rightExtensionBounds!.x).toBeGreaterThan(4); const contentBounds = await content.boundingBox(); expect(contentBounds).not.toBeNull(); expect(contentBounds!.width / symbolBounds!.width).toBeGreaterThan(0.59); expect(contentBounds!.width / symbolBounds!.width).toBeLessThan(0.62); }); test("PNCH023 与 PNCH012 使用相同的气室主体尺寸", async ({ page }) => { await page.goto("/"); const fixedNode = await dragSymbolToCanvas(page, "amesim_pnch023", { x: 300, y: 260, }); const variableNode = await dragSymbolToCanvas(page, "amesim_pnch012", { x: 540, y: 260, }); const fixedSymbol = fixedNode.locator( '[data-component-symbol="amesim_pnch023"]', ); const variableSymbol = variableNode.locator( '[data-component-symbol="amesim_pnch012"]', ); const fixedContent = fixedSymbol.locator('[data-pnch023-part="symbol"]'); const variableContent = variableSymbol.locator( '[data-pnch012-part="symbol"]', ); const fixedLabel = fixedSymbol.locator('[data-pnch023-part="label"]'); await expect(fixedNode.locator(".sim-node")).toHaveAttribute( "data-node-layout-tier", "small", ); await expect(fixedContent).toHaveAttribute("transform", /scale\(0\.45\)/); await expect( fixedSymbol.locator('[data-pnch023-part="chamber"]'), ).toHaveAttribute("r", "21"); await expect(fixedSymbol.locator('[data-pnch023-part="ports"]')).toHaveCSS( "stroke-width", "2px", ); await expect(fixedLabel).toHaveText("Cp"); await expect(fixedLabel).toHaveAttribute("fill", "#8b134f"); await expect(fixedLabel).toHaveCSS("font-weight", "600"); await expect(fixedLabel).toHaveCSS("stroke", "none"); expect(await fixedLabel.getAttribute("stroke-width")).toBeNull(); await expect(fixedContent.locator('[data-pnch023-part="label"]')).toHaveCount( 0, ); const renderedLabelSize = await fixedLabel.evaluate( (element: SVGTextElement) => { const svg = element.ownerSVGElement; if (!svg) { return 0; } const fontSize = Number.parseFloat(getComputedStyle(element).fontSize); const viewBoxScale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width; return fontSize * viewBoxScale; }, ); expect(renderedLabelSize).toBeCloseTo(15, 1); await expectSymbolAnchors(fixedNode, [ { portName: "port_1", x: 17.6, y: 24 }, { portName: "port_2", x: 46.4, y: 24 }, ]); const [fixedNodeBounds, fixedContentBounds, variableContentBounds] = await Promise.all([ fixedNode.boundingBox(), fixedContent.boundingBox(), variableContent.boundingBox(), ]); expect(fixedNodeBounds).not.toBeNull(); expect(fixedContentBounds).not.toBeNull(); expect(variableContentBounds).not.toBeNull(); expect(Math.abs(fixedNodeBounds!.width - 116)).toBeLessThan(1); expect(Math.abs(fixedNodeBounds!.height - 96)).toBeLessThan(1); expect( Math.abs(fixedContentBounds!.width - variableContentBounds!.width), ).toBeLessThan(3); }); test("PNCH012 按参考图使用 45% 结构与独立 15px 文字", async ({ page, }) => { await page.goto("/"); const node = await dragSymbolToCanvas(page, "amesim_pnch012", { x: 360, y: 260, }); const simNode = node.locator(".sim-node"); const symbol = node.locator('[data-component-symbol="amesim_pnch012"]'); const svg = symbol.locator("svg"); const content = svg.locator('[data-pnch012-part="symbol"]'); const structuralParts = svg.locator( '[data-pnch012-part="ports"], [data-pnch012-part="chamber"], [data-pnch012-part="volume-arrow"]', ); const chamber = svg.locator('[data-pnch012-part="chamber"]'); const arrows = svg.locator('[data-pnch012-part="volume-arrow"]'); const label = svg.locator('[data-pnch012-part="label"]'); await expect(simNode).toHaveAttribute("data-node-layout-tier", "small"); await expect(content).toHaveAttribute("transform", /scale\(0\.45\)/); await expect(chamber).toHaveAttribute("r", "21"); await expect(arrows).toHaveCount(4); await expect(structuralParts).toHaveCount(6); for (let index = 0; index < (await structuralParts.count()); index += 1) { const part = structuralParts.nth(index); await expect(part).toHaveCSS("stroke-width", "1.5px"); await expect(part).toHaveCSS("vector-effect", "non-scaling-stroke"); } await expect(label).toHaveText("Cp"); await expect(label).toHaveAttribute("font-weight", "600"); await expect(label).toHaveCSS("font-weight", "600"); await expect(label).toHaveCSS("stroke", "none"); expect(await label.getAttribute("stroke-width")).toBeNull(); await expect(label).toHaveCSS("vector-effect", "non-scaling-stroke"); await expect(content.locator('[data-pnch012-part="label"]')).toHaveCount(0); await expect(svg.locator(':scope > [data-pnch012-part="label"]')).toHaveCount( 1, ); const renderedLabelSize = await label.evaluate((element: SVGTextElement) => { const svg = element.ownerSVGElement; if (!svg) { return 0; } const fontSize = Number.parseFloat(getComputedStyle(element).fontSize); const viewBoxScale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width; return fontSize * viewBoxScale; }); expect(renderedLabelSize).toBeCloseTo(15, 1); await expectSymbolAnchors(node, [ { portName: "port_1", x: 17.6, y: 24 }, { portName: "port_3", x: 32, y: 9.6 }, { portName: "port_2", x: 46.4, y: 24 }, { portName: "port_4", x: 32, y: 38.4 }, ]); const nodeBounds = await node.boundingBox(); expect(nodeBounds).not.toBeNull(); expect(Math.abs(nodeBounds!.width - 116)).toBeLessThan(1); expect(Math.abs(nodeBounds!.height - 96)).toBeLessThan(1); const contentGeometry = await content.evaluate((element: SVGGElement) => { const bounds = element.getBBox(); const matrix = element.transform.baseVal.consolidate()?.matrix; return { height: bounds.height, scale: matrix?.a ?? 1, width: bounds.width, }; }); expect(contentGeometry.width).toBeCloseTo(64, 4); expect(contentGeometry.height).toBeCloseTo(64, 4); expect(contentGeometry.scale).toBeCloseTo(0.45, 4); expect((contentGeometry.width * contentGeometry.scale) / 64).toBeCloseTo( 0.45, 4, ); }); test("不同尺寸档位使用紧凑节点框且旋转时保持中心", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click(); const paneBounds = await page.locator(".flow-canvas .react-flow__pane").boundingBox(); expect(paneBounds).not.toBeNull(); const cases = [ { modelType: "amesim_pnor001", size: { width: 116, height: 96 }, target: { x: 130, y: 150 }, tier: "small", }, { modelType: "amesim_pnl00r", size: { width: 100, height: 80 }, target: { x: 360, y: 150 }, tier: "compact", }, { modelType: "amesim_pnvo001", size: { width: 116, height: 96 }, target: { x: 600, y: 150 }, tier: "small", }, { modelType: "amesim_mecmas21", size: { width: 132, height: 112 }, target: { x: 130, y: 390 }, tier: "standard", }, { modelType: "amesim_lmechn1", size: { width: 148, height: 128 }, target: { x: 360, y: 390 }, tier: "custom", }, { modelType: "amesim_helium_medium", size: { width: 112, height: 112 }, target: { x: 600, y: 390 }, tier: "standard", }, ] as const; const nodes = new Map(); for (const item of cases) { const node = await dragSymbolToCanvas(page, item.modelType, item.target); nodes.set(item.modelType, node); const simNode = node.locator(".sim-node"); await expect(simNode).toHaveAttribute("data-node-layout-tier", item.tier); const [nodeBounds, simNodeBounds] = await Promise.all([ node.boundingBox(), simNode.boundingBox(), ]); expect(nodeBounds).not.toBeNull(); expect(simNodeBounds).not.toBeNull(); expect(Math.abs(nodeBounds!.width - item.size.width)).toBeLessThan(1); expect(Math.abs(nodeBounds!.height - item.size.height)).toBeLessThan(1); expect(Math.abs(simNodeBounds!.width - item.size.width)).toBeLessThan(1); expect(Math.abs(simNodeBounds!.height - item.size.height)).toBeLessThan(1); expect( Math.abs( nodeBounds!.x + nodeBounds!.width / 2 - (paneBounds!.x + item.target.x), ), ).toBeLessThan(2); expect( Math.abs( nodeBounds!.y + nodeBounds!.height / 2 - (paneBounds!.y + item.target.y), ), ).toBeLessThan(2); } const compactNode = nodes.get("amesim_pnl00r") as Locator; await compactNode.locator('[data-symbol-envelope="true"]').click(); const beforeRotation = await compactNode.boundingBox(); expect(beforeRotation).not.toBeNull(); await page .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) .click(); const afterRotation = await compactNode.boundingBox(); expect(afterRotation).not.toBeNull(); expect(Math.abs(afterRotation!.width - beforeRotation!.height)).toBeLessThan(1); expect(Math.abs(afterRotation!.height - beforeRotation!.width)).toBeLessThan(1); expect( Math.abs( afterRotation!.x + afterRotation!.width / 2 - (beforeRotation!.x + beforeRotation!.width / 2), ), ).toBeLessThan(1.5); expect( Math.abs( afterRotation!.y + afterRotation!.height / 2 - (beforeRotation!.y + beforeRotation!.height / 2), ), ).toBeLessThan(1.5); const denseHandles = await ( nodes.get("amesim_lmechn1") as Locator ) .locator('.port-handle.react-flow__handle-left[data-port-name^="port_"]') .evaluateAll((elements) => elements .map((element) => { const bounds = element.getBoundingClientRect(); return { top: bounds.top, bottom: bounds.bottom }; }) .sort((first, second) => first.top - second.top), ); for (let index = 1; index < denseHandles.length; index += 1) { expect(denseHandles[index].top).toBeGreaterThanOrEqual( denseHandles[index - 1].bottom - 0.5, ); } await page.getByRole("button", { name: "开启网格吸附", exact: true }).click(); const snappedTarget = { x: 600, y: 610 }; const snappedNode = await dragSymbolToCanvas( page, "amesim_f000", snappedTarget, ); const snappedBounds = await snappedNode.boundingBox(); expect(snappedBounds).not.toBeNull(); expect( Math.abs( snappedBounds!.x + snappedBounds!.width / 2 - (paneBounds!.x + snappedTarget.x), ), ).toBeLessThanOrEqual(10); expect( Math.abs( snappedBounds!.y + snappedBounds!.height / 2 - (paneBounds!.y + snappedTarget.y), ), ).toBeLessThanOrEqual(10); await expect .poll(() => page.evaluate(() => { const raw = window.localStorage.getItem( "system-simulation-flow:autosave", ); const project = raw ? JSON.parse(raw) : null; const position = project?.nodes?.at(-1)?.position; return position ? { xRemainder: position.x % 18, yRemainder: position.y % 18 } : null; }), ) .toEqual({ xRemainder: 0, yRemainder: 0 }); }); 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 massOutline = symbol.locator('[data-mecmas-part="mass"]'); const envelope = node.locator('[data-symbol-envelope="true"]'); const useFriction = page.getByLabel("启用摩擦", { exact: true }); const stoptype = page.getByLabel("限位类型", { exact: true }); let fixedEnvelopeBounds: { height: number; width: number; x: number; y: number; } | null = null; await expectSymbolAnchors(node, [ { portName: "port_2", x: 10.4, y: 24 }, { portName: "port_1", x: 53.6, y: 24 }, ]); await expect(massOutline).toHaveAttribute("stroke-width", "2.5"); await expect(massOutline).toHaveCSS("stroke-width", "2.5px"); await expect(massOutline).toHaveCSS( "vector-effect", "non-scaling-stroke", ); await expect(state).toHaveAttribute("transform", /scale\(0\.72\)/); const fixedLayout = await node.evaluate((element) => { const nodeBounds = element.getBoundingClientRect(); const portCenters = Array.from( element.querySelectorAll(".port-handle"), ).map((handle) => { const bounds = handle.getBoundingClientRect(); return { name: handle.dataset.portName, x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2, }; }); return { height: nodeBounds.height, width: nodeBounds.width, x: nodeBounds.left, y: nodeBounds.top, portCenters, }; }); 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 -8 64 64"); const currentLayout = await node.evaluate((element) => { const nodeBounds = element.getBoundingClientRect(); return { height: nodeBounds.height, width: nodeBounds.width, x: nodeBounds.left, y: nodeBounds.top, portCenters: Array.from( element.querySelectorAll(".port-handle"), ).map((handle) => { const bounds = handle.getBoundingClientRect(); return { name: handle.dataset.portName, x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2, }; }), }; }); expect(Math.abs(currentLayout.width - fixedLayout.width)).toBeLessThan(0.5); expect(Math.abs(currentLayout.height - fixedLayout.height)).toBeLessThan(0.5); expect(Math.abs(currentLayout.x - fixedLayout.x)).toBeLessThan(0.5); expect(Math.abs(currentLayout.y - fixedLayout.y)).toBeLessThan(0.5); expect(currentLayout.portCenters).toEqual(fixedLayout.portCenters); const currentEnvelopeBounds = await envelope.boundingBox(); expect(currentEnvelopeBounds).not.toBeNull(); if (fixedEnvelopeBounds === null) { fixedEnvelopeBounds = currentEnvelopeBounds!; } else { expect(Math.abs(currentEnvelopeBounds!.x - fixedEnvelopeBounds.x)).toBeLessThan( 0.5, ); expect(Math.abs(currentEnvelopeBounds!.y - fixedEnvelopeBounds.y)).toBeLessThan( 0.5, ); expect( Math.abs(currentEnvelopeBounds!.width - fixedEnvelopeBounds.width), ).toBeLessThan(0.5); expect( Math.abs(currentEnvelopeBounds!.height - fixedEnvelopeBounds.height), ).toBeLessThan(0.5); } const aspectRatio = currentEnvelopeBounds!.width / currentEnvelopeBounds!.height; expect(aspectRatio).toBeGreaterThan(0.95); expect(aspectRatio).toBeLessThan(1.15); if (guides) { const [visualBounds, symbolBounds] = await Promise.all([ state.boundingBox(), symbol.boundingBox(), ]); expect(visualBounds).not.toBeNull(); expect(symbolBounds).not.toBeNull(); expect(visualBounds!.width / symbolBounds!.width).toBeGreaterThan(0.65); expect(visualBounds!.width / symbolBounds!.width).toBeLessThan(0.75); expect(visualBounds!.height / symbolBounds!.height).toBeGreaterThan(0.65); expect(visualBounds!.height / symbolBounds!.height).toBeLessThan(0.75); } }; await expectVariant({ friction: "disabled", guides: false, limits: "unlimited", lowerGuide: "none", variant: "plain-unlimited", }); 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, }; }); await useFriction.selectOption("2"); await expectVariant({ friction: "enabled", guides: true, limits: "unlimited", lowerGuide: "unlimited", variant: "friction-unlimited", }); 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", }); 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 expect(piston.locator(".sim-node")).toHaveAttribute( "data-node-layout-tier", "standard", ); const pistonBounds = await piston.boundingBox(); expect(pistonBounds).not.toBeNull(); expect(Math.abs(pistonBounds!.width - 132)).toBeLessThan(1); expect(Math.abs(pistonBounds!.height - 112)).toBeLessThan(1); 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/); const pnrp17Svg = piston.locator( '[data-component-symbol="amesim_pnrp17"] svg', ); await expect(pnrp17Svg.locator('[stroke-width="2"]')).toHaveCount(7); await expect(pnrp17Svg.locator('g[stroke-width="1.5"]')).toHaveCount(2); const renderedStrokes = await pnrp17Svg.evaluate((element) => { const graphics = Array.from( element.querySelectorAll("path, rect"), ).filter((graphic) => getComputedStyle(graphic).stroke !== "none"); return { independent: graphics.every( (graphic) => getComputedStyle(graphic).vectorEffect === "non-scaling-stroke", ), widths: graphics.reduce>((counts, graphic) => { const width = getComputedStyle(graphic).strokeWidth; counts[width] = (counts[width] ?? 0) + 1; return counts; }, {}), }; }); expect(renderedStrokes.independent).toBe(true); expect(renderedStrokes.widths).toEqual({ "1.5px": 8, "2px": 7 }); }); 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("批量选中时隐藏已连接接口编号并保留未连接接口编号", async ({ page, }) => { await page.goto("/"); const firstNode = await dragSymbolToCanvas(page, "amesim_pnor001", { x: 320, y: 280, }); const secondNode = await dragSymbolToCanvas(page, "amesim_pnor001", { x: 620, y: 280, }); const firstConnectedPort = firstNode.locator( '.port-handle[data-port-name="port_2"]', ); const secondConnectedPort = secondNode.locator( '.port-handle[data-port-name="port_1"]', ); await firstConnectedPort.click(); await secondConnectedPort.click(); const connectedEdge = page.locator(".flow-canvas .react-flow__edge"); await expect(connectedEdge).toHaveCount(1); await expect(connectedEdge.locator(".react-flow__edge-path")).toHaveCount(1); await expect(firstConnectedPort).toHaveAttribute("data-connected", "true"); await expect(secondConnectedPort).toHaveAttribute("data-connected", "true"); const pane = page.locator(".flow-canvas .react-flow__pane"); const [paneBounds, firstEnvelopeBounds, secondEnvelopeBounds] = await Promise.all([ pane.boundingBox(), firstNode.locator('[data-symbol-envelope="true"]').boundingBox(), secondNode.locator('[data-symbol-envelope="true"]').boundingBox(), ]); expect(paneBounds).not.toBeNull(); expect(firstEnvelopeBounds).not.toBeNull(); expect(secondEnvelopeBounds).not.toBeNull(); await pane.click({ position: { x: 20, y: 20 } }); const selectionStart = { x: Math.max( paneBounds!.x + 4, Math.min(firstEnvelopeBounds!.x, secondEnvelopeBounds!.x) - 14, ), y: Math.max( paneBounds!.y + 4, Math.min(firstEnvelopeBounds!.y, secondEnvelopeBounds!.y) - 14, ), }; const selectionEnd = { x: Math.min( paneBounds!.x + paneBounds!.width - 4, Math.max( firstEnvelopeBounds!.x + firstEnvelopeBounds!.width, secondEnvelopeBounds!.x + secondEnvelopeBounds!.width, ) + 14, ), y: Math.min( paneBounds!.y + paneBounds!.height - 4, Math.max( firstEnvelopeBounds!.y + firstEnvelopeBounds!.height, secondEnvelopeBounds!.y + secondEnvelopeBounds!.height, ) + 14, ), }; await page.mouse.move(selectionStart.x, selectionStart.y); await page.mouse.down(); await page.mouse.move(selectionEnd.x, selectionEnd.y, { steps: 12 }); await page.mouse.up(); await expect(firstNode).toHaveClass(/selected/); await expect(secondNode).toHaveClass(/selected/); await expectPortIndexLabels(firstNode, { port_1: "1" }); await expectPortIndexLabels(secondNode, { port_2: "2" }); await page.evaluate( () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())), ), ); await expect(connectedEdge).not.toHaveClass(/selected|editor-edge-selected/); await pane.click({ position: { x: 20, y: 20 } }); await firstNode.locator('[data-symbol-envelope="true"]').click(); await expect(firstNode).toHaveClass(/selected/); await expect(secondNode).not.toHaveClass(/selected/); await expectPortIndexLabels(firstNode, { port_1: "1", port_2: "2" }); await expect(secondNode.locator(".port-index-label")).toHaveCount(0); }); test("旧工程缺失接口标识时只修复唯一配对并拒绝歧义连接", async ({ page, }) => { await page.goto("/"); await page.evaluate(() => { const physicalPort = (name: string, side: "left" | "right") => ({ name, kind: "physical", domain: "pneumatic", nominalRole: "bidirectional", positiveFlowDirection: "intoComponent", side, }); const makeNode = ( id: string, componentType: string, x: number, y: number, ports: ReturnType[], ) => ({ id, type: "simulationComponent", position: { x, y }, data: { label: id, componentType, modelType: componentType, ports, parameters: {}, parameterUnits: {}, parameterScientificNotation: {}, rotation: 0, mirrored: false, }, }); window.localStorage.setItem( "system-simulation-flow:autosave", JSON.stringify({ name: "edge-handle-recovery", presentationLayoutVersion: 1, nodes: [ makeNode( "amesim_pnpl01_1", "amesim_pnpl01", 180, 160, [physicalPort(" port_1 ", "left")], ), makeNode( "amesim_pnpl01_2", "amesim_pnpl01", 420, 160, [physicalPort("port_1", "left")], ), makeNode( "amesim_pnor001_1", "amesim_pnor001", 180, 360, [physicalPort("port_1", "left"), physicalPort("port_2", "right")], ), makeNode( "amesim_pnor001_2", "amesim_pnor001", 420, 360, [physicalPort("port_1", "left"), physicalPort("port_2", "right")], ), ], edges: [ { id: "repairable-edge", source: "amesim_pnpl01_1", sourceHandle: null, target: "amesim_pnpl01_2", targetHandle: null, }, { id: "ambiguous-edge", source: "amesim_pnor001_1", sourceHandle: null, target: "amesim_pnor001_2", targetHandle: null, }, ], simulation: { t_start: 0, t_stop: 2, step: 0.1, max_step: 0.005, method: "BDF", }, }), ); }); await page.reload(); await page.getByRole("button", { name: "恢复", exact: true }).click(); const repairedNodes = ["amesim_pnpl01_1", "amesim_pnpl01_2"].map((id) => page.locator(`.flow-canvas .react-flow__node[data-id="${id}"]`), ); const ambiguousNodes = ["amesim_pnor001_1", "amesim_pnor001_2"].map((id) => page.locator(`.flow-canvas .react-flow__node[data-id="${id}"]`), ); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); for (const node of repairedNodes) { await expect(node.locator('.port-handle[data-port-name="port_1"]')).toHaveAttribute( "data-connected", "true", ); } for (const node of ambiguousNodes) { await expect(node.locator('.port-handle[data-connected="true"]')).toHaveCount(0); } await page.keyboard.press("Control+A"); for (const node of repairedNodes) { await expect(node).toHaveClass(/selected/); await expect(node.locator(".port-index-label")).toHaveCount(0); } for (const node of ambiguousNodes) { await expect(node).toHaveClass(/selected/); await expectPortIndexLabels(node, { port_1: "1", port_2: "2" }); } await expect(page.locator(".flow-canvas .react-flow__edge")).not.toHaveClass( /selected|editor-edge-selected/, ); }); 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: 12.8, y: 24 }, { portName: "port_2", x: 51.2, y: 24 }, ]; await expectSymbolAnchors(firstOrifice, firstOrificeAnchors); await expectSymbolAnchors(signalOrifice, [ { portName: "res", x: 27.35, y: 43.2 }, { portName: "port_2", x: 12.8, y: 24 }, { portName: "port_3", x: 51.2, y: 24 }, ]); await firstOrifice.locator('[data-symbol-envelope="true"]').click(); 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, 8, ); await expect(firstPort).toHaveAttribute("data-connection-state", "idle"); await expect(secondPort).toHaveAttribute("data-connection-state", "idle"); expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(6); 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(2); 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 targetChamber = await dragSymbolToCanvas(page, "amesim_pnch023", { x: 300, y: 320, }); const movingChamber = await dragSymbolToCanvas(page, "amesim_pnch023", { x: 620, y: 520, }); const leftTargetPort = targetChamber.locator( '.port-handle[data-port-name="port_1"]', ); const rightTargetPort = targetChamber.locator( '.port-handle[data-port-name="port_2"]', ); const movingLeftPort = movingChamber.locator( '.port-handle[data-port-name="port_1"]', ); const movingRightPort = movingChamber.locator( '.port-handle[data-port-name="port_2"]', ); await dragNodePortNearTarget( page, movingChamber, movingLeftPort, leftTargetPort, ); await expect(movingLeftPort).toHaveAttribute("data-connection-state", "origin"); await expect(movingRightPort).toHaveAttribute("data-connection-state", "origin"); await expect(leftTargetPort).toHaveAttribute( "data-connection-state", "compatible", ); await expect(rightTargetPort).toHaveAttribute( "data-connection-state", "compatible", ); await page.mouse.up(); await expect.poll(() => distanceBetween(movingLeftPort, leftTargetPort)).toBeLessThan(2); await expect.poll(() => distanceBetween(movingRightPort, rightTargetPort)).toBeLessThan(2); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2); await expect(page.locator(".flow-canvas .contact-edge-hit")).toHaveCount(2); await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0); for (const port of [ movingLeftPort, movingRightPort, leftTargetPort, rightTargetPort, ]) { await expect(port).toHaveAttribute("data-connected", "true"); } await page.getByRole("button", { name: "保存工程", exact: true }).click(); const savedEndpointKeys = await page.evaluate(() => { const raw = window.localStorage.getItem( "system-simulation-flow:project:demo-system", ); const edges = raw ? (JSON.parse(raw)?.edges ?? []) : []; return edges.flatMap( (edge: { source: string; sourceHandle?: string; target: string; targetHandle?: string; }) => [ `${edge.source}:${edge.sourceHandle ?? ""}`, `${edge.target}:${edge.targetHandle ?? ""}`, ], ); }); expect(savedEndpointKeys).toHaveLength(4); expect(new Set(savedEndpointKeys)).toHaveProperty("size", 4); }); 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(2); 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); await expect .poll(() => page.evaluate(() => { const raw = window.localStorage.getItem( "system-simulation-flow:autosave", ); const project = raw ? JSON.parse(raw) : null; return project ? { position: project.nodes?.[0]?.position, version: project.presentationLayoutVersion, hasNodeSize: "width" in (project.nodes?.[0] ?? {}) || "height" in (project.nodes?.[0] ?? {}), } : null; }), ) .toEqual({ position: { x: 208, y: 154 }, version: 1, hasNodeSize: false, }); await page.reload(); await page.getByRole("button", { name: "恢复", exact: true }).click(); await expect .poll(() => page.evaluate(() => { const raw = window.localStorage.getItem( "system-simulation-flow:autosave", ); const project = raw ? JSON.parse(raw) : null; return project?.nodes?.[0]?.position ?? null; }), ) .toEqual({ x: 208, y: 154 }); }); test("旧固定尺寸工程迁移后保持接触接口重合", async ({ page }) => { await page.goto("/"); await page.evaluate(() => { const makePort = (name: string, side: "left" | "right") => ({ name, kind: "physical", domain: "pneumatic", nominalRole: "bidirectional", positiveFlowDirection: "intoComponent", side, }); const makeNode = (id: string, x: number) => ({ id, type: "simulationComponent", position: { x, y: 160 }, data: { label: id, componentType: "amesim_pnor001", modelType: "amesim_pnor001", ports: [makePort("port_1", "left"), makePort("port_2", "right")], parameters: {}, parameterUnits: {}, rotation: 0, mirrored: false, }, }); window.localStorage.setItem( "system-simulation-flow:autosave", JSON.stringify({ name: "legacy-contact-layout", nodes: [makeNode("amesim_pnor001_1", 200), makeNode("amesim_pnor001_2", 305)], edges: [ { id: "legacy-contact-edge", source: "amesim_pnor001_1", target: "amesim_pnor001_2", sourceHandle: "port_2", targetHandle: "port_1", }, ], 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 firstPort = page.locator( '.react-flow__node[data-id="amesim_pnor001_1"] .port-handle[data-port-name="port_2"]', ); const secondPort = page.locator( '.react-flow__node[data-id="amesim_pnor001_2"] .port-handle[data-port-name="port_1"]', ); await expect(firstPort).toBeAttached(); await expect(secondPort).toBeAttached(); expect(await distanceBetween(firstPort, secondPort)).toBeLessThan(0.5); await expect .poll(() => page.evaluate(() => { const raw = window.localStorage.getItem( "system-simulation-flow:autosave", ); const project = raw ? JSON.parse(raw) : null; return project ? { positions: project.nodes.map( (node: { position: { x: number; y: number } }) => ({ x: Number(node.position.x.toFixed(3)), y: Number(node.position.y.toFixed(3)), }), ), version: project.presentationLayoutVersion, } : null; }), ) .toEqual({ positions: [ { x: 231.7, y: 154 }, { x: 289.3, y: 154 }, ], version: 1, }); }); 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(Math.abs(nodeBounds!.width - 112)).toBeLessThan(1); expect(Math.abs(nodeBounds!.height - 112)).toBeLessThan(1); 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 outsideTightNode = { x: nodeBounds!.x - 8, y: envelopeBounds!.y + envelopeBounds!.height / 2, }; await page.mouse.click(outsideTightNode.x, outsideTightNode.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"); }); test("旋转后的低占比专用图标框选只按实际图形包络命中", async ({ page }) => { await page.goto("/"); const pane = page.locator(".flow-canvas .react-flow__pane"); const node = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 430, y: 300, }); const simNode = node.locator(".sim-node"); const envelope = node.locator('[data-symbol-envelope="true"]'); const selection = page.locator(".flow-canvas .react-flow__selection"); // PNPL01 的主体只占 compact 画布的一小部分;旋转后仍应使用实际图形 // 包络框选,不能因为选择框碰到透明的 React Flow 外层节点而误选。 await page .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) .click(); await expect(simNode).toHaveAttribute("data-node-layout-width", "80"); await expect(simNode).toHaveAttribute("data-node-layout-height", "100"); const paneBounds = await pane.boundingBox(); const nodeBounds = await node.boundingBox(); const envelopeBounds = await envelope.boundingBox(); expect(paneBounds).not.toBeNull(); expect(nodeBounds).not.toBeNull(); expect(envelopeBounds).not.toBeNull(); await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8); await expect(node).not.toHaveClass(/selected/); const blankStart = { x: nodeBounds!.x + nodeBounds!.width - 18, y: nodeBounds!.y + nodeBounds!.height - 18, }; const blankEnd = { x: nodeBounds!.x + nodeBounds!.width - 6, y: nodeBounds!.y + nodeBounds!.height - 6, }; expect(blankStart.x).toBeGreaterThan( envelopeBounds!.x + envelopeBounds!.width + 2, ); expect(blankStart.y).toBeGreaterThan( envelopeBounds!.y + envelopeBounds!.height + 2, ); await page.mouse.move(blankStart.x, blankStart.y); await page.mouse.down(); await page.mouse.move(blankEnd.x, blankEnd.y, { steps: 6 }); await expect(selection).toHaveCount(1); await page.mouse.up(); await expect(selection).toHaveCount(0); await expect(node).not.toHaveClass(/selected/); const envelopeStart = { x: envelopeBounds!.x - 8, y: envelopeBounds!.y - 8, }; const envelopeEnd = { x: envelopeBounds!.x + envelopeBounds!.width / 2, y: envelopeBounds!.y + envelopeBounds!.height / 2, }; await page.mouse.move(envelopeStart.x, envelopeStart.y); await page.mouse.down(); await page.mouse.move(envelopeEnd.x, envelopeEnd.y, { steps: 8 }); await expect(selection).toHaveCount(1); await page.mouse.up(); await expect(selection).toHaveCount(0); await expect(node).toHaveClass(/selected/); await expect(envelope).toHaveCSS("outline-style", "dashed"); const clickTarget = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 650, y: 300, }); const clickTargetEnvelope = clickTarget.locator( '[data-symbol-envelope="true"]', ); const clickTargetEnvelopeBounds = await clickTargetEnvelope.boundingBox(); expect(clickTargetEnvelopeBounds).not.toBeNull(); await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8); await expect(node).not.toHaveClass(/selected/); await expect(clickTarget).not.toHaveClass(/selected/); await page.mouse.move(envelopeStart.x, envelopeStart.y); await page.mouse.down(); await page.mouse.move(envelopeEnd.x, envelopeEnd.y, { steps: 8 }); await expect(selection).toHaveCount(1); await pane.dispatchEvent("pointercancel", { bubbles: true, button: 0, buttons: 0, clientX: envelopeEnd.x, clientY: envelopeEnd.y, isPrimary: true, pointerId: 1, pointerType: "mouse", }); await expect(selection).toHaveCount(0); await expect(node).toHaveClass(/selected/); await expect(clickTarget).not.toHaveClass(/selected/); await expect(envelope).toHaveCSS("outline-style", "dashed"); // Playwright 不知道页面内已派发 pointercancel;在画布外释放其鼠标状态, // 避免额外 mouseup 经过 React Flow 并替我们清理内部框选标记。 await page.mouse.move(4, 4); await page.mouse.up(); await clickTargetEnvelope.click(); await expect(selection).toHaveCount(0); await expect(node).not.toHaveClass(/selected/); await expect(clickTarget).toHaveClass(/selected/); }); test("零高度及纯水平框选不会复用上一帧的节点和边选择", async ({ page }) => { await page.goto("/"); const pane = page.locator(".flow-canvas .react-flow__pane"); const firstNode = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 350, y: 300, }); const secondNode = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 650, y: 300, }); const firstHandle = firstNode.locator( '.port-handle[data-port-name="port_1"]', ); const secondHandle = secondNode.locator( '.port-handle[data-port-name="port_1"]', ); await firstHandle.click(); await secondHandle.click(); const edge = page.locator(".flow-canvas .react-flow__edge"); const selection = page.locator(".flow-canvas .react-flow__selection"); const firstEnvelopeBounds = await firstNode .locator('[data-symbol-envelope="true"]') .boundingBox(); const paneBounds = await pane.boundingBox(); expect(firstEnvelopeBounds).not.toBeNull(); expect(paneBounds).not.toBeNull(); await expect(edge).toHaveCount(1); await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8); await expect(firstNode).not.toHaveClass(/selected/); await expect(secondNode).not.toHaveClass(/selected/); await expect(edge).not.toHaveClass(/selected|editor-edge-selected/); const selectionStart = { x: firstEnvelopeBounds!.x - 8, y: firstEnvelopeBounds!.y - 8, }; const positiveAreaEnd = { x: firstEnvelopeBounds!.x + firstEnvelopeBounds!.width + 8, y: firstEnvelopeBounds!.y + firstEnvelopeBounds!.height + 8, }; const zeroHeightEnd = { x: positiveAreaEnd.x, y: selectionStart.y, }; await page.mouse.move(selectionStart.x, selectionStart.y); await page.mouse.down(); await page.mouse.move(positiveAreaEnd.x, positiveAreaEnd.y, { steps: 8 }); await expect(selection).toHaveCount(1); await expect(firstNode).toHaveClass(/selected/); await expect(edge).toHaveClass(/selected|editor-edge-selected/); await page.mouse.move(zeroHeightEnd.x, zeroHeightEnd.y, { steps: 6 }); await expect(selection).toHaveCount(1); await page.mouse.up(); await expect(selection).toHaveCount(0); await expect(firstNode).not.toHaveClass(/selected/); await expect(secondNode).not.toHaveClass(/selected/); await expect(edge).not.toHaveClass(/selected|editor-edge-selected/); // 新一轮手势从一开始就是纯水平框选,不得复用上一轮的正面积矩形。 await page.mouse.move(selectionStart.x, selectionStart.y); await page.mouse.down(); await page.mouse.move(zeroHeightEnd.x, zeroHeightEnd.y, { steps: 8 }); await expect(selection).toHaveCount(1); await page.mouse.up(); await expect(selection).toHaveCount(0); await expect(firstNode).not.toHaveClass(/selected/); await expect(secondNode).not.toHaveClass(/selected/); await expect(edge).not.toHaveClass(/selected|editor-edge-selected/); }); test("window blur 会结束活动框选且不吞掉恢复后的首次节点点击", async ({ page, }) => { await page.goto("/"); const pane = page.locator(".flow-canvas .react-flow__pane"); const canvas = page.locator(".flow-canvas"); const firstNode = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 350, y: 300, }); const secondNode = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 650, y: 300, }); const firstEnvelope = firstNode.locator('[data-symbol-envelope="true"]'); const secondEnvelope = secondNode.locator('[data-symbol-envelope="true"]'); const selection = page.locator(".flow-canvas .react-flow__selection"); const paneBounds = await pane.boundingBox(); const canvasBounds = await canvas.boundingBox(); const firstEnvelopeBounds = await firstEnvelope.boundingBox(); expect(paneBounds).not.toBeNull(); expect(canvasBounds).not.toBeNull(); expect(firstEnvelopeBounds).not.toBeNull(); await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8); await expect(firstNode).not.toHaveClass(/selected/); await expect(secondNode).not.toHaveClass(/selected/); const selectionStart = { x: firstEnvelopeBounds!.x - 8, y: firstEnvelopeBounds!.y - 8, }; const selectionEnd = { x: firstEnvelopeBounds!.x + firstEnvelopeBounds!.width / 2, y: firstEnvelopeBounds!.y + firstEnvelopeBounds!.height / 2, }; await page.mouse.move(selectionStart.x, selectionStart.y); await page.mouse.down(); await page.mouse.move(selectionEnd.x, selectionEnd.y, { steps: 8 }); await expect(selection).toHaveCount(1); await page.evaluate(() => window.dispatchEvent(new FocusEvent("blur"))); await expect(selection).toHaveCount(0); await expect(firstNode).toHaveClass(/selected/); await expect(secondNode).not.toHaveClass(/selected/); // blur 已经完成框选收尾;这里只在画布外释放 Playwright 的鼠标设备状态。 const outsideCanvas = { x: Math.max(1, canvasBounds!.x - 8), y: Math.max(1, canvasBounds!.y - 8), }; expect(outsideCanvas.x).toBeLessThan(canvasBounds!.x); await page.mouse.move(outsideCanvas.x, outsideCanvas.y); await page.mouse.up(); await expect(selection).toHaveCount(0); await expect(firstNode).toHaveClass(/selected/); await secondEnvelope.click(); await expect(firstNode).not.toHaveClass(/selected/); await expect(secondNode).toHaveClass(/selected/); await expect(firstNode.locator(".port-index-label")).toHaveCount(0); await expect(secondNode.locator(".port-index-label")).toHaveCount(1); }); test("多节点框选不会把仅接触透明外框的专用图标加入选择集", async ({ page, }) => { await page.goto("/"); const pane = page.locator(".flow-canvas .react-flow__pane"); const firstNode = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 350, y: 300, }); const secondNode = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 520, y: 300, }); const thirdNode = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 700, y: 300, }); const firstEnvelope = firstNode.locator('[data-symbol-envelope="true"]'); const secondEnvelope = secondNode.locator('[data-symbol-envelope="true"]'); const selection = page.locator(".flow-canvas .react-flow__selection"); const secondHandle = secondNode.locator( '.port-handle[data-port-name="port_1"]', ); const thirdHandle = thirdNode.locator( '.port-handle[data-port-name="port_1"]', ); await secondHandle.click(); await thirdHandle.click(); const unrelatedEdge = page.locator(".flow-canvas .react-flow__edge"); await expect(unrelatedEdge).toHaveCount(1); const paneBounds = await pane.boundingBox(); const firstEnvelopeBounds = await firstEnvelope.boundingBox(); const secondNodeBounds = await secondNode.boundingBox(); const secondEnvelopeBounds = await secondEnvelope.boundingBox(); expect(paneBounds).not.toBeNull(); expect(firstEnvelopeBounds).not.toBeNull(); expect(secondNodeBounds).not.toBeNull(); expect(secondEnvelopeBounds).not.toBeNull(); await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8); await expect(firstNode).not.toHaveClass(/selected/); await expect(secondNode).not.toHaveClass(/selected/); await expect(thirdNode).not.toHaveClass(/selected/); const selectionStart = { x: firstEnvelopeBounds!.x - 8, y: firstEnvelopeBounds!.y - 8, }; const selectionEnd = { x: secondEnvelopeBounds!.x - 6, y: firstEnvelopeBounds!.y + firstEnvelopeBounds!.height + 8, }; expect(selectionEnd.x).toBeGreaterThan(secondNodeBounds!.x + 2); expect(selectionEnd.x).toBeLessThan(secondEnvelopeBounds!.x - 2); await page.mouse.move(selectionStart.x, selectionStart.y); await page.mouse.down(); await page.mouse.move(selectionEnd.x, selectionEnd.y, { steps: 12 }); await expect(selection).toHaveCount(1); await page.mouse.up(); await expect(selection).toHaveCount(0); await expect(firstNode).toHaveClass(/selected/); await expect(secondNode).not.toHaveClass(/selected/); await expect(thirdNode).not.toHaveClass(/selected/); await expect(unrelatedEdge).not.toHaveClass( /selected|editor-edge-selected/, ); await expect(firstNode.locator(".port-index-label")).toHaveCount(1); await expect(secondNode.locator(".port-index-label")).toHaveCount(0); await expect(thirdNode.locator(".port-index-label")).toHaveCount(0); await page .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) .click(); await expect(firstNode.locator(".sim-node")).toHaveAttribute( "data-node-layout-width", "80", ); await expect(firstNode.locator(".sim-node")).toHaveAttribute( "data-node-layout-height", "100", ); await expect(secondNode.locator(".sim-node")).toHaveAttribute( "data-node-layout-width", "100", ); await expect(secondNode.locator(".sim-node")).toHaveAttribute( "data-node-layout-height", "80", ); await expect(thirdNode.locator(".sim-node")).toHaveAttribute( "data-node-layout-width", "100", ); await expect(thirdNode.locator(".sim-node")).toHaveAttribute( "data-node-layout-height", "80", ); await expect(secondNode.locator(".port-index-label")).toHaveCount(0); await expect(thirdNode.locator(".port-index-label")).toHaveCount(0); await expect(unrelatedEdge).not.toHaveClass( /selected|editor-edge-selected/, ); });