import { expect, test, type Locator, type Page } from "@playwright/test"; import { expandSimulationConsole } from "./fixtures"; 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_pnl00r" || modelType === "amesim_pnl0001" || modelType === "amesim_pnl0002" || modelType === "amesim_pnl0003" ) { return [ physicalPort("port_1", "left", 10), physicalPort("port_2", "right", 20), ]; } if (modelType === "amesim_pn3node2") { return [ physicalPort("port_1", "left", 10), physicalPort("port_2", "right", 20), physicalPort("port_3", "right", 30), ]; } if (modelType === "amesim_p4node2") { return [ physicalPort("port_1", "left", 10), physicalPort("port_2", "right", 20), physicalPort("port_3", "right", 30), physicalPort("port_4", "right", 40), ]; } 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_lstp00a") { return [ mechanicalPort("port_1", "left", 10), mechanicalPort("port_2", "right", 20), ]; } if (modelType === "amesim_lmechn1") { return [ ...Array.from({ length: 20 }, (_, index) => mechanicalPort(`port_${index + 1}`, "right", (index + 1) * 10), ), mechanicalPort("port_21", "left", 210), ]; } if (modelType === "amesim_pnrp17") { return [ physicalPort("port_1", "left", 10), mechanicalPort("port_3", "left", 20), mechanicalPort("port_2", "left", 30), mechanicalPort("port_4", "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 clickLocatorCenter(page: Page, locator: Locator) { const center = await locatorCenter(locator); await page.mouse.click(center.x, center.y); } async function expectNoActivePortConnection(page: Page) { await expect( page.locator( '.port-handle[data-connection-state="origin"], .port-handle[data-connection-state="compatible"], .port-handle[data-connection-state="incompatible"]', ), ).toHaveCount(0); await expect(page.locator(".flow-canvas .react-flow__connection")).toHaveCount(0); } 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: item.modelType === "amesim_mecmas21" || item.modelType === "amesim_lmechn1" || item.modelType === "amesim_forc" ? "0.2.0" : "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: 2, minimum: 1, maximum: 2, minimumExclusive: false, editor: "choice", options: [ { value: 1, label: "否" }, { value: 2, label: "是" }, ], }, { name: "stoptype", label: "限位类型", quantity: "dimensionless", unit: "", default: 1, minimum: 1, maximum: 4, minimumExclusive: false, editor: "choice", options: [ { value: 1, label: "理想限位" }, { value: 2, label: "弹性限位" }, { value: 3, label: "恢复碰撞" }, { value: 4, label: "无限位" }, ], }, ] : item.modelType === "amesim_forc" ? [ { name: "direction", label: "力方向", description: "图标旋转和镜像不会改变该参数。", quantity: "dimensionless", unit: "", default: 1, minimumExclusive: false, editor: "choice", options: [ { value: 1, label: "正向" }, { value: -1, label: "反向" }, ], }, ] : item.modelType === "amesim_lmechn1" ? [ { name: "v1", label: "右侧端口数", description: "设置工作区中显示的右侧机械端口数量,最多 20 个。", quantity: "dimensionless", unit: "", default: 2, minimum: 1, maximum: 20, minimumExclusive: false, }, { name: "sum", label: "节点求和模式", quantity: "dimensionless", unit: "", default: 1, minimumExclusive: false, editor: "choice", options: [ { value: 1, 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("LSTP00A 使用 standard 画布、65% 工作区占比、3px 线宽和外露引线", async ({ page, }) => { await page.goto("/"); const paletteSymbol = page.locator( 'aside.palette [data-component-symbol="amesim_lstp00a"]', ); await expect(paletteSymbol).toHaveAttribute("data-symbol-size-tier", "standard"); await expect(paletteSymbol).toHaveAttribute("data-palette-occupancy", "0.9"); const node = await dragSymbolToCanvas(page, "amesim_lstp00a", { x: 420, y: 280, }); const simNode = node.locator(".sim-node"); const symbol = node.locator('[data-component-symbol="amesim_lstp00a"]'); const content = symbol.locator('[data-lstp00a-part="symbol"]'); const lineParts = symbol.locator( '[data-lstp00a-part="left-extension"], [data-lstp00a-part="right-extension"], [data-lstp00a-part="left-socket"], [data-lstp00a-part="right-socket"], [data-lstp00a-part="left-rail"], [data-lstp00a-part="middle-rail"], [data-lstp00a-part="right-rail"], [data-lstp00a-part="spring"], [data-lstp00a-part="lower-link"]', ); const leftExtension = symbol.locator( '[data-lstp00a-part="left-extension"]', ); const rightExtension = symbol.locator( '[data-lstp00a-part="right-extension"]', ); const leftHandle = node.locator('.port-handle[data-port-name="port_1"]'); 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\.693333/); await expect(content).toHaveAttribute("stroke", "#00af00"); await expect(content).toHaveAttribute("stroke-width", "3"); await expect(lineParts).toHaveCount(9); for (let index = 0; index < (await lineParts.count()); index += 1) { await expect(lineParts.nth(index)).toHaveCSS("stroke-width", "3px"); await expect(lineParts.nth(index)).toHaveCSS( "vector-effect", "non-scaling-stroke", ); } await expectSymbolAnchors(node, [ { portName: "port_1", x: 11.2, y: 24 }, { portName: "port_2", x: 52.8, y: 24 }, ]); const [ nodeBounds, symbolBounds, contentGeometry, 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, }; }), leftExtension.boundingBox(), rightExtension.boundingBox(), leftHandle.boundingBox(), rightHandle.boundingBox(), ]); expect(nodeBounds).not.toBeNull(); expect(symbolBounds).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.height).toBeCloseTo(53, 4); expect(contentGeometry.scale).toBeCloseTo(0.693333, 4); expect( (contentGeometry.width * contentGeometry.scale) / 64, ).toBeCloseTo(0.65, 4); expect(symbolBounds!.width / symbolBounds!.height).toBeCloseTo(1, 2); expect( leftExtensionBounds!.x + leftExtensionBounds!.width - (leftHandleBounds!.x + leftHandleBounds!.width), ).toBeGreaterThan(1.5); expect(rightHandleBounds!.x - rightExtensionBounds!.x).toBeGreaterThan(1.5); }); 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: 132, height: 112 }, target: { x: 360, y: 150 }, tier: "standard", }, { 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: 96 }, 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 pipeNode = nodes.get("amesim_pnl00r") as Locator; await pipeNode.locator('[data-symbol-envelope="true"]').click(); const beforeRotation = await pipeNode.boundingBox(); expect(beforeRotation).not.toBeNull(); await page .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) .click(); const afterRotation = await pipeNode.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-right[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("LMECHN1 按右侧端口数展开工作区画布且组件库图标保持固定", async ({ page, }) => { await page.goto("/"); const paletteSymbol = page.locator( 'aside.palette [data-component-symbol="amesim_lmechn1"]', ); await expect(paletteSymbol).toHaveAttribute("data-palette-occupancy", "0.9"); await expect( paletteSymbol.locator('[data-lmechn1-part="right-port-socket"]'), ).toHaveCount(5); await expect( paletteSymbol.locator('[data-lmechn1-part="reference-port-socket"]'), ).toHaveCount(1); const paletteBounds = await paletteSymbol.boundingBox(); expect(paletteBounds).not.toBeNull(); const node = await dragSymbolToCanvas(page, "amesim_lmechn1", { x: 520, y: 330, }); const initialBounds = await node.boundingBox(); expect(initialBounds).not.toBeNull(); expect(Math.abs(initialBounds!.width - 148)).toBeLessThan(1); expect(Math.abs(initialBounds!.height - 96)).toBeLessThan(1); const body = node.locator('[data-lmechn1-part="body"]'); await expect(body).toHaveAttribute("stroke-width", "2.5"); await expect( node.locator('[data-lmechn1-part="right-port-socket"]'), ).toHaveCount(2); await expect( node.locator('[data-lmechn1-part="reference-port-socket"]'), ).toHaveCount(1); const detailStrokeWidths = await node .locator( '[data-lmechn1-part="right-port-line"], [data-lmechn1-part="right-port-socket"], [data-lmechn1-part="reference-port-line"], [data-lmechn1-part="reference-port-socket"]', ) .evaluateAll((elements) => elements.map((element) => element.getAttribute("stroke-width")), ); expect(new Set(detailStrokeWidths)).toEqual(new Set(["1.5"])); const connectorGeometry = await node .locator("g[data-lmechn1-port]") .evaluateAll((groups) => groups.map((group) => { const socket = group.querySelector( '[data-lmechn1-part="right-port-socket"]', ); const line = group.querySelector( '[data-lmechn1-part="right-port-line"]', ); if (!socket || !line) { throw new Error("LMECHN1 connector geometry is incomplete"); } const socketBox = socket.getBBox(); const lineBox = line.getBBox(); return { axisOffset: Math.abs( socketBox.y + socketBox.height / 2 - (lineBox.y + lineBox.height / 2), ), lineWidth: lineBox.width, socketHeight: socketBox.height, socketWidth: socketBox.width, }; }), ); expect(connectorGeometry).toHaveLength(2); for (const connector of connectorGeometry) { expect(Math.abs(connector.socketWidth - 6)).toBeLessThan(0.01); expect(Math.abs(connector.socketHeight - 5)).toBeLessThan(0.01); expect(Math.abs(connector.lineWidth - 10)).toBeLessThan(0.01); expect(connector.axisOffset).toBeLessThan(0.01); } await expect(node.locator(".port-handle")).toHaveCount(3); await expect( node.locator('.port-handle.react-flow__handle-left[data-port-name="port_3"]'), ).toHaveCount(1); await expect( node.locator('.port-handle.react-flow__handle-right[data-port-name^="port_"]'), ).toHaveCount(2); await expectPortIndexLabels(node, { port_1: "1", port_2: "2", port_3: "3" }); const portCount = page.getByRole("textbox", { name: "右侧端口数" }); await portCount.fill("1"); await portCount.press("Enter"); await expect(node.locator(".port-handle")).toHaveCount(2); await expect( node.locator('.port-handle.react-flow__handle-left[data-port-name="port_2"]'), ).toHaveCount(1); await expect( node.locator('.port-handle.react-flow__handle-right[data-port-name="port_1"]'), ).toHaveCount(1); await expect( node.locator('[data-lmechn1-part="right-port-socket"]'), ).toHaveCount(1); await expectPortIndexLabels(node, { port_1: "1", port_2: "2" }); const onePortBounds = await node.boundingBox(); expect(onePortBounds).not.toBeNull(); expect(Math.abs(onePortBounds!.height - 96)).toBeLessThan(1); expect( Math.abs( onePortBounds!.x + onePortBounds!.width / 2 - (initialBounds!.x + initialBounds!.width / 2), ), ).toBeLessThan(1.5); expect( Math.abs( onePortBounds!.y + onePortBounds!.height / 2 - (initialBounds!.y + initialBounds!.height / 2), ), ).toBeLessThan(1.5); await portCount.fill("20"); await portCount.press("Enter"); await expect(node.locator(".port-handle")).toHaveCount(21); await expect( node.locator('.port-handle.react-flow__handle-left[data-port-name="port_21"]'), ).toHaveCount(1); await expect( node.locator('.port-handle.react-flow__handle-right[data-port-name^="port_"]'), ).toHaveCount(20); await expect( node.locator('[data-lmechn1-part="right-port-socket"]'), ).toHaveCount(20); const twentyPortBounds = await node.boundingBox(); expect(twentyPortBounds).not.toBeNull(); expect(Math.abs(twentyPortBounds!.height - 534)).toBeLessThan(1); await expect( page.getByRole("combobox", { name: "节点求和模式" }), ).toHaveCount(0); await expect(page.getByText("节点求和模式", { exact: true })).toHaveCount(0); const rightPortLabels = node.locator( '.port-index-label:not([data-port-name="port_21"])', ); await expect(rightPortLabels).toHaveCount(20); const verticalLabelLayout = await rightPortLabels.evaluateAll((elements) => { const boxes = elements .map((element) => element.getBoundingClientRect()) .sort((first, second) => first.top - second.top); const centers = boxes.map((box) => box.left + box.width / 2); return { columnSpread: Math.max(...centers) - Math.min(...centers), overlaps: boxes.slice(1).some((box, index) => box.top < boxes[index].bottom), }; }); expect(verticalLabelLayout.columnSpread).toBeLessThan(1); expect(verticalLabelLayout.overlaps).toBe(false); await page .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) .click(); const horizontalLabelLayout = await rightPortLabels.evaluateAll((elements) => { const boxes = elements .map((element) => element.getBoundingClientRect()) .sort((first, second) => first.left - second.left); const centers = boxes.map((box) => box.top + box.height / 2); return { rowSpread: Math.max(...centers) - Math.min(...centers), }; }); expect(horizontalLabelLayout.rowSpread).toBeLessThan(1); await portCount.fill("21"); await portCount.press("Enter"); await expect( page.getByText("右侧端口数量不能超过20", { exact: true }), ).toBeVisible(); await expect(node.locator(".port-handle")).toHaveCount(21); const paletteBoundsAfter = await paletteSymbol.boundingBox(); expect(paletteBoundsAfter).not.toBeNull(); expect(Math.abs(paletteBoundsAfter!.width - paletteBounds!.width)).toBeLessThan( 0.5, ); expect( Math.abs(paletteBoundsAfter!.height - paletteBounds!.height), ).toBeLessThan(0.5); }); test("旧版 LMECHN1 工程会把固定参考端口迁移到动态最大编号", async ({ page, }) => { await page.goto("/"); const node = await dragSymbolToCanvas(page, "amesim_lmechn1", { x: 520, y: 330, }); const portCount = page.getByRole("textbox", { name: "右侧端口数" }); await portCount.fill("2"); await portCount.press("Enter"); const terminator = await dragSymbolToCanvas(page, "amesim_f000", { x: 280, y: 330, }); await node.locator('[data-port-name="port_3"]').click(); await terminator.locator('[data-port-name="port_1"]').click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await page.getByRole("button", { name: "保存工程", exact: true }).click(); const downgraded = await page.evaluate(() => { const key = Object.keys(localStorage).find((candidate) => candidate.startsWith("system-simulation-flow:project:"), ); const raw = key ? localStorage.getItem(key) : null; if (!key || !raw) { return false; } const project = JSON.parse(raw) as { nodes: Array<{ id: string; data: { componentType: string; modelVersion?: string; ports: unknown[]; parameters: Record; }; }>; edges: Array<{ source: string; target: string; sourceHandle: string; targetHandle: string; }>; }; const legacyNode = project.nodes.find( (candidate) => candidate.data.componentType === "amesim_lmechn1", ); if (!legacyNode) { return false; } legacyNode.data.modelVersion = "0.1.0"; legacyNode.data.ports = legacyNode.data.ports.slice(0, 9); legacyNode.data.parameters.sum = 2; project.edges.forEach((edge) => { if (edge.source === legacyNode.id) { edge.sourceHandle = "port_9"; } if (edge.target === legacyNode.id) { edge.targetHandle = "port_9"; } }); localStorage.setItem(key, JSON.stringify(project)); return true; }); expect(downgraded).toBe(true); await page.getByRole("button", { name: "加载工程", exact: true }).click(); const migratedNode = page.locator( '.flow-canvas .react-flow__node[data-id^="amesim_lmechn1_"]', ); await expect(migratedNode.locator(".port-handle")).toHaveCount(3); await expect( migratedNode.locator( '.port-handle.react-flow__handle-left[data-port-name="port_3"]', ), ).toHaveCount(1); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await page.getByRole("button", { name: "展开仿真控制台" }).click(); await expect( page.getByRole("complementary", { name: "仿真控制台", exact: true }), ).toContainText("已升级 1 个 LMECHN1 元件的动态端口合同"); await page.getByRole("button", { name: "保存工程", exact: true }).click(); await expect .poll(() => page.evaluate(() => { const key = Object.keys(localStorage).find((candidate) => candidate.startsWith("system-simulation-flow:project:"), ); const raw = key ? localStorage.getItem(key) : null; const project = raw ? JSON.parse(raw) : null; const migrated = project?.nodes?.find( (candidate: { data?: { componentType?: string } }) => candidate.data?.componentType === "amesim_lmechn1", ); const edge = project?.edges?.[0]; const endpoint = edge?.source === migrated?.id ? edge?.sourceHandle : edge?.target === migrated?.id ? edge?.targetHandle : null; return { modelVersion: migrated?.data?.modelVersion, portCount: migrated?.data?.ports?.length, sum: migrated?.data?.parameters?.sum, endpoint, }; }), ) .toEqual({ modelVersion: "0.2.0", portCount: 21, sum: 1, endpoint: "port_3", }); }); test("LMECHN1 动态参考端口使用实际锚点参与接触吸附", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click(); const node = await dragSymbolToCanvas(page, "amesim_lmechn1", { x: 600, y: 330, }); const portCount = page.getByRole("textbox", { name: "右侧端口数" }); await portCount.fill("1"); await portCount.press("Enter"); const boundary = await dragSymbolToCanvas(page, "amesim_f000", { x: 260, y: 330, }); const referencePort = node.locator( '.port-handle.react-flow__handle-left[data-port-name="port_2"]', ); const boundaryPort = boundary.locator( '.port-handle[data-port-name="port_1"]', ); await dragNodePortNearTarget( page, boundary, boundaryPort, referencePort, 8, ); await expect(referencePort).toHaveAttribute( "data-connection-state", "compatible", ); await expect(boundaryPort).toHaveAttribute("data-connection-state", "origin"); await page.mouse.up(); await expect .poll(() => distanceBetween(boundaryPort, referencePort)) .toBeLessThan(2); const contactEdge = page.locator(".flow-canvas .react-flow__edge"); await expect(contactEdge).toHaveCount(1); await expect(contactEdge).toHaveClass(/editor-edge-contact/); await expect(contactEdge.locator(".react-flow__edge-path")).toHaveCount(0); }); test("FORC 的显式力方向不会随工作区旋转或镜像翻转", async ({ page }) => { await page.goto("/"); const node = await dragSymbolToCanvas(page, "amesim_forc", { x: 440, y: 300, }); const direction = page.getByRole("combobox", { name: "力方向" }); await direction.selectOption("-1"); await expect(direction).toHaveValue("-1"); await page .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) .click(); await page .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) .click(); await page .getByRole("button", { name: "水平镜像 (Shift+H)", exact: true }) .click(); await expect(direction).toHaveValue("-1"); await expect .poll(() => page.evaluate(() => { const raw = localStorage.getItem("system-simulation-flow:autosave"); const project = raw ? JSON.parse(raw) : null; const force = project?.nodes?.find( (candidate: { data?: { componentType?: string } }) => candidate.data?.componentType === "amesim_forc", ); return force ? { direction: force.data.parameters.direction, mirrored: force.data.mirrored, rotation: force.data.rotation, } : null; }), ) .toEqual({ direction: -1, mirrored: true, rotation: 180 }); await page.getByRole("button", { name: "生成系统 XML" }).click(); await expandSimulationConsole(page); const xml = page.getByLabel("生成的系统 XML"); await expect(xml).toContainText('name="direction" value="-1"'); await expect(xml).not.toContainText("rotation="); await expect(xml).not.toContainText("mirrored="); await expect(node).toHaveClass(/selected/); }); test("旧版 FORC 工程会迁移为默认正向且不再依赖图标旋转", async ({ page, }) => { await page.goto("/"); await dragSymbolToCanvas(page, "amesim_forc", { x: 440, y: 300 }); await page.getByRole("button", { name: "保存工程", exact: true }).click(); const downgraded = await page.evaluate(() => { const key = Object.keys(localStorage).find((candidate) => candidate.startsWith("system-simulation-flow:project:"), ); const raw = key ? localStorage.getItem(key) : null; if (!key || !raw) { return false; } const project = JSON.parse(raw) as { nodes: Array<{ data: { componentType: string; modelVersion?: string; parameters: Record; }; }>; }; const force = project.nodes.find( (candidate) => candidate.data.componentType === "amesim_forc", ); if (!force) { return false; } force.data.modelVersion = "0.1.0"; delete force.data.parameters.direction; localStorage.setItem(key, JSON.stringify(project)); return true; }); expect(downgraded).toBe(true); await page.getByRole("button", { name: "加载工程", exact: true }).click(); const force = page.locator( '.flow-canvas .react-flow__node[data-id^="amesim_forc_"]', ); await force.locator('[data-symbol-envelope="true"]').click(); await expect(page.getByRole("combobox", { name: "力方向" })).toHaveValue("1"); await page.getByRole("button", { name: "展开仿真控制台" }).click(); await expect( page.getByRole("complementary", { name: "仿真控制台", exact: true }), ).toContainText("已升级 1 个 FORC 元件的显式力方向合同"); await page.getByRole("button", { name: "保存工程", exact: true }).click(); await expect .poll(() => page.evaluate(() => { const key = Object.keys(localStorage).find((candidate) => candidate.startsWith("system-simulation-flow:project:"), ); const raw = key ? localStorage.getItem(key) : null; const project = raw ? JSON.parse(raw) : null; const force = project?.nodes?.find( (candidate: { data?: { componentType?: string } }) => candidate.data?.componentType === "amesim_forc", ); return { modelVersion: force?.data?.modelVersion, direction: force?.data?.parameters?.direction, }; }), ) .toEqual({ modelVersion: "0.2.0", direction: 1 }); }); test("MECMAS21 图标按摩擦和限位参数切换四种参考外观", async ({ page }) => { await page.goto("/"); const paletteState = page.locator( 'aside.palette [data-component-symbol="amesim_mecmas21"] [data-mecmas-variant]', ); await expect(paletteState).toHaveAttribute( "data-mecmas-variant", "friction-limited", ); await expect(paletteState).toHaveAttribute("data-mecmas-friction", "enabled"); await expect(paletteState).toHaveAttribute("data-mecmas-limits", "finite"); 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\)/); await expect(useFriction).toHaveValue("2"); await expect(stoptype).toHaveValue("1"); 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: "enabled", guides: true, limits: "finite", lowerGuide: "finite", variant: "friction-limited", }); 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 stoptype.selectOption("4"); await expectVariant({ friction: "enabled", guides: true, limits: "unlimited", lowerGuide: "unlimited", variant: "friction-unlimited", }); await useFriction.selectOption("1"); await expectVariant({ friction: "disabled", guides: false, limits: "unlimited", lowerGuide: "none", variant: "plain-unlimited", }); await stoptype.selectOption("2"); await expectVariant({ friction: "disabled", guides: true, limits: "finite", lowerGuide: "finite", variant: "plain-limited", }); await useFriction.selectOption("2"); await expectVariant({ friction: "enabled", guides: true, limits: "finite", lowerGuide: "finite", variant: "friction-limited", }); 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: 52.935 }, { portName: "port_3", x: 2, y: 0.179 }, { portName: "port_2", x: 2, y: 24.016 }, { portName: "port_4", x: 62, y: 0.179 }, { portName: "port_5", x: 62, y: 24.016 }, ]); 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.1"]')).toHaveCount(8); await expect(pnrp17Svg.locator('[stroke-width="1.9"]')).toHaveCount(2); const arrowPairs = pnrp17Svg.locator( '[data-pnrp17-part="chamber-arrows"]', ); await expect(arrowPairs).toHaveCount(2); for (let pairIndex = 0; pairIndex < 2; pairIndex += 1) { const pair = arrowPairs.nth(pairIndex); const leftArrow = pair.locator('[data-pnrp17-arrow="left"]'); const rightArrow = pair.locator('[data-pnrp17-arrow="right"]'); await expect(leftArrow.locator("path").nth(0)).toHaveAttribute( "d", "M9.1 0H3.9", ); await expect(rightArrow.locator("path").nth(0)).toHaveAttribute( "d", "M9.1 0H3.9", ); await expect(leftArrow.locator("path").nth(1)).toHaveAttribute( "d", "M3.9 -2.5 0 0 3.9 2.5Z", ); await expect(rightArrow.locator("path").nth(1)).toHaveAttribute( "d", "M3.9 -2.5 0 0 3.9 2.5Z", ); await expect(leftArrow).toHaveAttribute("transform", "translate(18.2 0)"); await expect(rightArrow).toHaveAttribute( "transform", "translate(38.8 0) scale(-1 1)", ); } const upperShell = pnrp17Svg.locator( '[data-pnrp17-part="upper-shell"]', ); await expect(upperShell).toHaveAttribute( "d", "M13.4 1.1H51.4V3h4.1v2.9h-4.1v1.9H18.2V21.1H3.3V7.3H13.4V5.9H9.3V3h4.1Z", ); await expect( pnrp17Svg.locator('[data-pnrp17-part="upper-left-wall"]'), ).toHaveAttribute("width", "2.9"); await expect( pnrp17Svg.locator('[data-pnrp17-part="lower-left-shell"]'), ).toHaveAttribute("d", "M3.3 27H18.2V40.7H20V46.6H3.3Z"); await expect( pnrp17Svg.locator('[data-pnrp17-part="lower-left-wall"]'), ).toHaveAttribute("width", "2.9"); const pistonSleeve = pnrp17Svg.locator( '[data-pnrp17-part="piston-sleeve"]', ); await expect(pistonSleeve).toHaveAttribute("x", "40.8"); await expect(pistonSleeve).toHaveAttribute("width", "7.7"); expect( await pnrp17Svg .locator('[data-pnrp17-part="symbol"] > [data-pnrp17-part]') .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-pnrp17-part")), ), ).toEqual([ "upper-shell", "upper-left-wall", "lower-left-shell", "lower-left-wall", "lower-base", "piston-sleeve", "piston-face", "horizontal-rod-left", "horizontal-rod-right", "horizontal-extensions", "bottom-port", "chamber-arrows", "chamber-arrows", ]); const bottomPort = pnrp17Svg.locator( '[data-pnrp17-part="bottom-port"]', ); await expect(bottomPort).toHaveAttribute("d", "M25.5 43.4V48"); await expect(bottomPort).toHaveAttribute("fill", "none"); await expect( pnrp17Svg.locator('[data-pnrp17-part="symbol"]'), ).toHaveAttribute("transform", /scale\(1 1\.21\)/); await expect( pnrp17Svg.locator('[data-pnrp17-part="horizontal-extensions"]'), ).toHaveAttribute( "d", "M2 4.4h7.3m46.2 0H62M2 24.1h7.3m46.2 0H62", ); 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.9px": 8, "2.1px": 8 }); }); test("PN3NODE2 与 P4NODE2 使用参考图直线汇流结构且粗线对应 port_2", async ({ page, }) => { await page.goto("/"); const cases = [ { anchors: [ { portName: "port_1", x: 36.6, y: 11.2 }, { portName: "port_2", x: 24.6, y: 24 }, { portName: "port_3", x: 36.6, y: 36.8 }, ], center: { x: 36.6, y: 24 }, finePath: "M36.6 11.2V36.8M24.6 24H27.5", modelType: "amesim_pn3node2", positions: { port_1: "top", port_2: "left", port_3: "bottom", }, thickPath: "M27.5 24H36.6", }, { anchors: [ { portName: "port_1", x: 32, y: 11.2 }, { portName: "port_2", x: 19.2, y: 24 }, { portName: "port_3", x: 44.8, y: 24 }, { portName: "port_4", x: 32, y: 36.8 }, ], center: { x: 32, y: 24 }, finePath: "M32 11.2V36.8M32 24H44.8M19.2 24H22.1", modelType: "amesim_p4node2", positions: { port_1: "top", port_2: "left", port_3: "right", port_4: "bottom", }, thickPath: "M22.1 24H32", }, ] as const; for (const [index, item] of cases.entries()) { const node = await dragSymbolToCanvas(page, item.modelType, { x: 300 + index * 300, y: 300, }); await expect(node.locator(".sim-node")).toHaveAttribute( "data-node-layout-tier", "small", ); const bounds = await node.boundingBox(); expect(bounds).not.toBeNull(); expect(Math.abs(bounds!.width - 116)).toBeLessThan(1); expect(Math.abs(bounds!.height - 96)).toBeLessThan(1); await expectSymbolAnchors(node, [...item.anchors]); for (const [portName, position] of Object.entries(item.positions)) { await expect( node.locator(`.port-handle[data-port-name="${portName}"]`), ).toHaveClass(new RegExp(`react-flow__handle-${position}`)); } const svg = node.locator( `[data-component-symbol="${item.modelType}"] svg`, ); const fineBranches = svg.locator('[data-junction-part="fine-branches"]'); await expect(fineBranches).toHaveAttribute("d", item.finePath); await expect(fineBranches).toHaveAttribute("stroke-width", "1.5"); const collector = svg.locator('[data-junction-part="collector-branch"]'); await expect(collector).toHaveAttribute("d", item.thickPath); await expect(collector).toHaveAttribute("data-port-name", "port_2"); await expect(collector).toHaveAttribute("stroke-width", "4"); const center = svg.locator('[data-junction-part="center"]'); await expect(center).toHaveAttribute("cx", String(item.center.x)); await expect(center).toHaveAttribute("cy", String(item.center.y)); await expect(center).toHaveAttribute("r", "2.75"); await expect(center).toHaveAttribute("fill", "#8b134f"); const geometry = await svg.evaluate((element: SVGSVGElement) => { const graphicBounds = element.getBBox(); const viewBox = element.viewBox.baseVal; return { occupancy: Math.max(graphicBounds.width, graphicBounds.height) / Math.max(viewBox.width, viewBox.height), }; }); expect(geometry.occupancy).toBeCloseTo(0.4, 4); } }); test("PNL00R 与 PNL0001-0003 使用 standard 画布和 60% 工作区占比", async ({ page, }) => { await page.goto("/"); const modelTypes = [ "amesim_pnl00r", "amesim_pnl0001", "amesim_pnl0002", "amesim_pnl0003", ] as const; for (const [index, modelType] of modelTypes.entries()) { const paletteSymbol = page.locator( `aside.palette [data-component-symbol="${modelType}"]`, ); await expect(paletteSymbol).toHaveAttribute("data-symbol-size-tier", "standard"); await expect(paletteSymbol).toHaveAttribute("data-palette-occupancy", "0.9"); const node = await dragSymbolToCanvas(page, modelType, { x: 230 + (index % 2) * 330, y: 220 + Math.floor(index / 2) * 260, }); await expect(node.locator(".sim-node")).toHaveAttribute( "data-node-layout-tier", "standard", ); const bounds = await node.boundingBox(); expect(bounds).not.toBeNull(); expect(Math.abs(bounds!.width - 132)).toBeLessThan(1); expect(Math.abs(bounds!.height - 112)).toBeLessThan(1); await expectSymbolAnchors(node, [ { portName: "port_1", x: 12.8, y: 24 }, { portName: "port_2", x: 51.2, y: 24 }, ]); const symbol = node.locator(`[data-component-symbol="${modelType}"]`); await expect(symbol).toHaveAttribute("data-symbol-view-box", "0 -8 64 64"); const content = symbol.locator('[data-pipe-network-content="true"]'); await expect(content).toHaveAttribute( "transform", "translate(32 24) scale(0.64) translate(-32 -24)", ); const geometry = await content.evaluate((element: SVGGElement) => { const contentBounds = element.getBBox(); const matrix = element.transform.baseVal.consolidate()?.matrix; const scale = matrix?.a ?? 1; return { occupancy: (Math.max(contentBounds.width, contentBounds.height) * scale) / 64, }; }); expect(geometry.occupancy).toBeCloseTo(0.6, 4); if (modelType === "amesim_pnl0001") { const causalLabels = symbol.locator("text"); await expect(causalLabels).toHaveText(["R", "C"]); const labelPositions = await causalLabels.evaluateAll((labels) => labels.map((label) => Number(label.getAttribute("x"))), ); expect(labelPositions[0]).toBeLessThan(labelPositions[1]); } } }); 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, }); const multiPortCount = page.getByRole("textbox", { name: "右侧端口数" }); await multiPortCount.fill("8"); await multiPortCount.press("Enter"); await expect(multiPortNode.locator(".port-handle")).toHaveCount(9); 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("缺失或错误工程版本、字符串端口和缺失 Handle 均拒绝恢复", async ({ page, }) => { await page.goto("/"); const invalidVariants = [ "missing-version", "wrong-version", "string-port", "missing-handle", ] as const; for (const variant of invalidVariants) { await page.evaluate((invalidVariant) => { const port = { name: "port_1", kind: "physical", domain: "pneumatic", nominalRole: "bidirectional", positiveFlowDirection: "intoComponent", side: "left", }; const makeNode = (id: string, x: number) => ({ id, type: "simulationComponent", position: { x, y: 160 }, data: { label: id, componentType: "amesim_pnpl01", modelType: "amesim_pnpl01", ports: [{ ...port }], parameters: {}, parameterUnits: {}, parameterScientificNotation: {}, rotation: 0, mirrored: false, }, }); const project: Record = { projectSchemaVersion: 1, name: `invalid-${invalidVariant}`, nodes: [ makeNode("amesim_pnpl01_1", 180), makeNode("amesim_pnpl01_2", 420), ], edges: [ { id: "edge-1", source: "amesim_pnpl01_1", sourceHandle: "port_1", target: "amesim_pnpl01_2", targetHandle: "port_1", data: { isContactEdge: false }, }, ], simulation: { t_start: 0, t_stop: 2, step: 0.1, max_step: 0.005, method: "BDF", }, }; if (invalidVariant === "missing-version") { delete project.projectSchemaVersion; } else if (invalidVariant === "wrong-version") { project.projectSchemaVersion = 2; } else if (invalidVariant === "string-port") { project.nodes[0].data.ports = ["port_1"]; } else { project.edges[0].sourceHandle = null; } window.localStorage.setItem( "system-simulation-flow:autosave", JSON.stringify(project), ); }, variant); await page.reload(); await expect(page.getByRole("button", { name: "恢复", exact: true })).toHaveCount(0); } }); test("恢复工程时采用当前目录端口并明确丢弃已删除端口的连接", async ({ page, }) => { await page.goto("/"); const firstNode = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 300, y: 260, }); const secondNode = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 600, y: 260, }); await firstNode.locator('[data-port-name="port_1"]').click(); await secondNode.locator('[data-port-name="port_1"]').click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await page.getByRole("button", { name: "保存工程", exact: true }).click(); const changed = await page.evaluate(() => { const key = Object.keys(window.localStorage).find((candidate) => candidate.startsWith("system-simulation-flow:project:"), ); const raw = key ? window.localStorage.getItem(key) : null; if (!key || !raw) { return false; } const project = JSON.parse(raw) as { nodes?: Array<{ data?: { ports?: Array> } }>; edges?: Array<{ id?: string; sourceHandle?: string; targetHandle?: string; }>; }; project.nodes?.forEach((node) => { if (node.data?.ports?.[0]) { node.data.ports[0].name = "legacy_port"; } }); const edge = project.edges?.[0]; if (!edge) { return false; } edge.id = "legacy-edge"; edge.sourceHandle = "legacy_port"; edge.targetHandle = "legacy_port"; window.localStorage.setItem(key, JSON.stringify(project)); return true; }); expect(changed).toBe(true); await page.getByRole("button", { name: "加载工程", exact: true }).click(); const nodes = page.locator(".flow-canvas .react-flow__node"); await expect(nodes).toHaveCount(2); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); await expect(nodes.locator('[data-port-name="legacy_port"]')).toHaveCount(0); await expect(nodes.first().locator('[data-port-name="port_1"]')).toBeVisible(); await page.getByRole("button", { name: "展开仿真控制台" }).click(); await expect( page.getByRole("complementary", { name: "仿真控制台", exact: true }), ).toContainText( "已丢弃失效连接 legacy-edge:端口 amesim_pnpl01_1.legacy_port、amesim_pnpl01_2.legacy_port 不在当前组件目录中", ); }); test("AMESim canvas nodes use icon anchors and highlight only compatible free ports", async ({ page, }) => { const pageErrors: string[] = []; page.on("pageerror", (error) => pageErrors.push(error.message)); 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"]', ); for (const port of [firstPhysicalOutput, signalOutput]) { await expect(port).toHaveCSS("width", "6px"); await expect(port).toHaveCSS("height", "6px"); await expect(port).toHaveCSS("border-top-width", "0px"); await expect(port.locator(".port-handle-glyph-shape")).toHaveCSS( "stroke-width", "1px", ); const hitAreaSize = await port.evaluate((element) => { const visualStyle = getComputedStyle(element); const hitAreaStyle = getComputedStyle(element, "::before"); return { height: Number.parseFloat(visualStyle.height) - Number.parseFloat(hitAreaStyle.top) - Number.parseFloat(hitAreaStyle.bottom), width: Number.parseFloat(visualStyle.width) - Number.parseFloat(hitAreaStyle.left) - Number.parseFloat(hitAreaStyle.right), }; }); expect(Math.abs(hitAreaSize.width - 15)).toBeLessThan(0.01); expect(Math.abs(hitAreaSize.height - 15)).toBeLessThan(0.01); const bounds = await port.boundingBox(); expect(bounds).not.toBeNull(); const hitTarget = await page.evaluate( ({ x, y }) => document .elementFromPoint(x, y) ?.closest(".port-handle") ?.getAttribute("data-port-name") ?? null, { x: bounds!.x - 3, y: bounds!.y + bounds!.height / 2, }, ); expect(hitTarget).toBe(await port.getAttribute("data-port-name")); } const physicalPortShape = await firstPhysicalOutput.evaluate((element) => { const bounds = element.getBoundingClientRect(); const style = getComputedStyle(element); return { borderRadius: style.borderTopLeftRadius, height: bounds.height, width: bounds.width, }; }); expect(Math.abs(physicalPortShape.width - physicalPortShape.height)).toBeLessThan( 0.01, ); expect(physicalPortShape.borderRadius).toBe("50%"); const physicalCircle = firstPhysicalOutput.locator( '[data-port-glyph-shape="circle"]', ); await expect(physicalCircle).toHaveAttribute("cx", "3"); await expect(physicalCircle).toHaveAttribute("cy", "3"); await expect(physicalCircle).toHaveAttribute("r", "2.5"); await expect(physicalCircle).not.toHaveAttribute( "vector-effect", "non-scaling-stroke", ); 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", "origin", ); const manualDraft = page.locator(".flow-canvas .manual-connection-draft"); await expect(manualDraft).toBeVisible(); await expect(manualDraft.locator(".react-flow__connection-path")).toHaveAttribute( "data-waypoint-count", "1", ); await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(1); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); await page.keyboard.press("Escape"); await expect(firstPhysicalOutput).toHaveAttribute( "data-connection-state", "idle", ); await expect(manualDraft).toHaveCount(0); 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 page.locator(".flow-canvas .react-flow__pane").click({ position: { x: 70, y: 90 }, }); await expect(manualDraft.locator(".react-flow__connection-path")).toHaveAttribute( "data-waypoint-count", "1", ); await compatiblePhysicalPort.click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await expect(manualDraft).toHaveCount(0); 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(); await expect(physicalEdge.locator(".edge-endpoint-blocker")).toHaveCount(0); await expect(physicalEdge.locator(".react-flow__edge-interaction")).toHaveCount(1); await expect(physicalEdge).not.toHaveClass(/editor-edge-contact/); await page.getByRole("button", { name: "保存工程", exact: true }).click(); const routedEdge = await page.evaluate(() => { const raw = window.localStorage.getItem( "system-simulation-flow:project:demo-system", ); const project = raw ? JSON.parse(raw) : null; return project?.edges?.[0] ?? null; }); expect(routedEdge?.data?.routePoints?.length).toBeGreaterThan(0); await signalOutput.click(); await expect(signalOutput).toHaveAttribute("data-connection-state", "origin"); await clickLocatorCenter(page, firstPhysicalOutput); await expectNoActivePortConnection(page); 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(physicalEdge).not.toHaveClass(/selected|editor-edge-selected/); await clickLocatorCenter(page, compatiblePhysicalPort); await expectNoActivePortConnection(page); await expect(physicalEdge).not.toHaveClass(/selected|editor-edge-selected/); 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"); await expect(signalOutput).toHaveCSS("opacity", "0"); await expect(signalInput).toHaveCSS("opacity", "0"); await expect(signalOutput).toHaveCSS("pointer-events", "none"); await expect(signalInput).toHaveCSS("pointer-events", "none"); const renderedEdges = page.locator(".flow-canvas .react-flow__edge"); const clickEdgeMiddle = async (edgeIndex: number) => { const point = await renderedEdges .nth(edgeIndex) .locator(".react-flow__edge-interaction") .evaluate((path: SVGPathElement) => { const localPoint = 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( localPoint.x, localPoint.y, ).matrixTransform(matrix); return { x: screenPoint.x, y: screenPoint.y }; }); await page.mouse.click(point.x, point.y); }; await clickEdgeMiddle(0); await expect(renderedEdges).toHaveCount(2); await expect(renderedEdges.nth(0)).toHaveClass( /selected|editor-edge-selected/, ); await clickEdgeMiddle(1); await expect(renderedEdges).toHaveCount(2); await expect(renderedEdges.nth(1)).toHaveClass( /selected|editor-edge-selected/, ); await expect(page.getByText("System Simulation Flow", { exact: true })).toBeVisible(); expect(pageErrors).toEqual([]); }); test("端口图标随画布缩小且物理端口始终保持标准圆形", async ({ page }) => { await page.goto("/"); const node = await dragSymbolToCanvas(page, "amesim_lmechn1", { x: 560, y: 360, }); const portCount = page.getByRole("textbox", { name: "右侧端口数" }); await portCount.fill("8"); await portCount.press("Enter"); await dragSymbolToCanvas(page, "amesim_step0", { x: 780, y: 260, }); const physicalPorts = node.locator(".port-handle-physical"); await expect(physicalPorts).toHaveCount(9); const scalablePorts = page.locator(".flow-canvas .port-handle"); await expect(scalablePorts).toHaveCount(10); const viewport = page.locator(".flow-canvas .react-flow__viewport"); const readViewportZoom = () => viewport.evaluate((element) => { const values = getComputedStyle(element) .transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?.map(Number) ?? []; return values[0] ?? 1; }); const readPortShapes = async () => { const shapes = await scalablePorts.evaluateAll((elements) => elements.map((element) => { const handleBounds = element.getBoundingClientRect(); const glyph = element.querySelector(".port-handle-glyph"); const glyphBounds = glyph?.getBoundingClientRect(); const glyphShape = element.querySelector( ".port-handle-glyph-shape", ); const circle = element.querySelector( '[data-port-glyph-shape="circle"]', ); const matrix = glyphShape?.getScreenCTM(); return { borderRadius: getComputedStyle(element).borderTopLeftRadius, circle: circle ? { cx: circle.cx.baseVal.value, cy: circle.cy.baseVal.value, r: circle.r.baseVal.value, } : null, glyphHeight: glyphBounds?.height ?? null, glyphWidth: glyphBounds?.width ?? null, handleHeight: handleBounds.height, handleWidth: handleBounds.width, physical: element.classList.contains("port-handle-physical"), scaleX: matrix ? Math.hypot(matrix.a, matrix.b) : null, scaleY: matrix ? Math.hypot(matrix.c, matrix.d) : null, }; }), ); for (const shape of shapes) { expect(Math.abs(shape.handleWidth - 6)).toBeLessThan(0.05); expect(Math.abs(shape.handleHeight - 6)).toBeLessThan(0.05); expect(shape.glyphWidth).not.toBeNull(); expect(shape.glyphHeight).not.toBeNull(); expect(Math.abs(shape.glyphWidth! - shape.glyphHeight!)).toBeLessThan(0.01); if (shape.physical) { expect(shape.borderRadius).toBe("50%"); expect(shape.circle).toEqual({ cx: 3, cy: 3, r: 2.5 }); } else { expect(shape.borderRadius).toBe("1.5px"); expect(shape.circle).toBeNull(); } expect(shape.scaleX).not.toBeNull(); expect(shape.scaleY).not.toBeNull(); expect(Math.abs(shape.scaleX! - shape.scaleY!)).toBeLessThan(0.0001); } return shapes; }; const initialZoom = await readViewportZoom(); const initialShapes = await readPortShapes(); for (const shape of initialShapes) { expect(Math.abs(shape.glyphWidth! - 6 * initialZoom)).toBeLessThan(0.05); expect(Math.abs(shape.glyphHeight! - 6 * initialZoom)).toBeLessThan(0.05); } const pane = page.locator(".flow-canvas .react-flow__pane"); const paneBounds = await pane.boundingBox(); expect(paneBounds).not.toBeNull(); await page.mouse.move( paneBounds!.x + paneBounds!.width / 2, paneBounds!.y + paneBounds!.height / 2, ); await page.keyboard.down("Control"); await page.mouse.wheel(0, 720); await page.keyboard.up("Control"); await expect .poll(async () => (await readViewportZoom()) / initialZoom) .toBeLessThan(0.7); const zoomedZoom = await readViewportZoom(); const zoomedShapes = await readPortShapes(); const zoomRatio = zoomedZoom / initialZoom; for (const [index, shape] of zoomedShapes.entries()) { expect(Math.abs(shape.glyphWidth! - 6 * zoomedZoom)).toBeLessThan(0.1); expect(Math.abs(shape.glyphHeight! - 6 * zoomedZoom)).toBeLessThan(0.1); expect( Math.abs(shape.glyphWidth! / initialShapes[index].glyphWidth! - zoomRatio), ).toBeLessThan(0.02); } }); test("放大画面后接触吸附仍保留足够的画布判定半径", async ({ page }) => { await page.goto("/"); const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", { x: 410, y: 300, }); const boundary = await dragSymbolToCanvas(page, "amesim_pnpl01", { x: 540, y: 300, }); const pane = page.locator(".flow-canvas .react-flow__pane"); const paneBounds = await pane.boundingBox(); expect(paneBounds).not.toBeNull(); await page.mouse.move( paneBounds!.x + paneBounds!.width / 2, paneBounds!.y + paneBounds!.height / 2, ); await page.keyboard.down("Control"); await page.mouse.wheel(0, -960); await page.keyboard.up("Control"); const viewport = page.locator(".flow-canvas .react-flow__viewport"); const readZoom = () => viewport.evaluate((element) => { const values = getComputedStyle(element) .transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?.map(Number) ?? []; return values[0] ?? 1; }); await expect.poll(readZoom).toBeGreaterThan(1.5); const firstPort = firstOrifice.locator( '.port-handle[data-port-name="port_2"]', ); const secondPort = boundary.locator( '.port-handle[data-port-name="port_1"]', ); await dragNodePortNearTarget( page, boundary, secondPort, firstPort, 12, ); expect(await distanceBetween(firstPort, secondPort)).toBeLessThan(20); await expect(firstPort).toHaveAttribute( "data-connection-state", "compatible", ); await expect(secondPort).toHaveAttribute( "data-connection-state", "origin", ); expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(10); await page.mouse.up(); await expect .poll(() => distanceBetween(firstPort, secondPort)) .toBeLessThan(2); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); }); 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, 12, ); await expect(firstPort).toHaveAttribute("data-connection-state", "idle"); await expect(secondPort).toHaveAttribute("data-connection-state", "idle"); expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(10); await page.mouse.up(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0); await dragNodePortNearTarget( page, secondOrifice, secondPort, firstPort, 8, ); await expect(firstPort).toHaveAttribute( "data-connection-state", "compatible", ); await expect(secondPort).toHaveAttribute( "data-connection-state", "origin", ); expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(6); 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 contactEdge = page.locator(".flow-canvas .react-flow__edge"); await expect(page.locator(".flow-canvas .contact-edge-hit")).toHaveCount(0); await expect(page.locator(".flow-canvas .contact-edge-marker")).toHaveCount(0); await expect( page.getByRole("button", { name: "接触连接", exact: true }), ).toHaveCount(0); await expect(contactEdge).not.toHaveClass(/selectable/); await expect(contactEdge).not.toHaveAttribute("tabindex", "0"); await expect(contactEdge.locator(".react-flow__edge-interaction")).toHaveCount(0); await expect(contactEdge.locator(".edge-endpoint-blocker")).toHaveCount(0); 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"); const firstPortCenter = await locatorCenter(firstPort); const endpointTarget = await page.evaluate( ({ x, y }) => { const target = document.elementFromPoint(x, y); return { className: target?.getAttribute("class") ?? "", nodeId: target?.closest(".react-flow__node")?.getAttribute("data-id") ?? null, }; }, firstPortCenter, ); expect(endpointTarget.className).not.toContain("edge-endpoint-blocker"); expect(endpointTarget.className).not.toContain("port-handle"); expect(endpointTarget.className).not.toContain("react-flow__edge-interaction"); expect(endpointTarget.nodeId).not.toBeNull(); const unusedPort = firstOrifice.locator( '.port-handle[data-port-name="port_1"]', ); await unusedPort.click(); await expect(unusedPort).toHaveAttribute("data-connection-state", "origin"); await page.mouse.click(firstPortCenter.x, firstPortCenter.y); await expectNoActivePortConnection(page); await expect(contactEdge).not.toHaveClass(/selected|editor-edge-selected/); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await expect(firstPort).toHaveAttribute("data-connected", "true"); await expect(secondPort).toHaveAttribute("data-connected", "true"); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); await expandSimulationConsole(page); 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 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("PNCH012 四端口接触连接后主体各方向仍可直接选中", async ({ page, }) => { await page.goto("/"); await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click(); const chamber = await dragSymbolToCanvas(page, "amesim_pnch012", { x: 500, y: 350, }); const chamberPorts = { left: chamber.locator('.port-handle[data-port-name="port_1"]'), right: chamber.locator('.port-handle[data-port-name="port_2"]'), top: chamber.locator('.port-handle[data-port-name="port_3"]'), bottom: chamber.locator('.port-handle[data-port-name="port_4"]'), }; const rotateButton = page.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true, }); const snapBoundary = async ( targetPosition: { x: number; y: number }, targetPort: Locator, rotationSteps: number, ) => { const boundary = await dragSymbolToCanvas( page, "amesim_pnpl01", targetPosition, ); for (let step = 0; step < rotationSteps; step += 1) { await rotateButton.click(); } const boundaryPort = boundary.locator( '.port-handle[data-port-name="port_1"]', ); await dragNodePortNearTarget(page, boundary, boundaryPort, targetPort); await page.mouse.up(); await expect(boundaryPort).toHaveAttribute("data-connected", "true"); await expect(targetPort).toHaveAttribute("data-connected", "true"); }; await snapBoundary({ x: 720, y: 350 }, chamberPorts.right, 0); await snapBoundary({ x: 500, y: 160 }, chamberPorts.top, 3); await snapBoundary({ x: 280, y: 350 }, chamberPorts.left, 2); await snapBoundary({ x: 500, y: 540 }, chamberPorts.bottom, 1); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(4); await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0); await expect(page.locator(".flow-canvas .react-flow__edge-interaction")).toHaveCount(0); await expect(page.locator(".flow-canvas .edge-endpoint-blocker")).toHaveCount(0); for (const port of Object.values(chamberPorts)) { await expect(port).toHaveCSS("opacity", "0"); await expect(port).toHaveCSS("pointer-events", "none"); } const envelope = chamber.locator('[data-symbol-envelope="true"]'); const envelopeBounds = await envelope.boundingBox(); expect(envelopeBounds).not.toBeNull(); const pane = page.locator(".flow-canvas .react-flow__pane"); for (const [xRatio, yRatio] of [ [0.5, 0.5], [0.25, 0.5], [0.75, 0.5], [0.5, 0.25], [0.5, 0.75], ] as const) { await pane.click({ position: { x: 12, y: 12 } }); await page.mouse.click( envelopeBounds!.x + envelopeBounds!.width * xRatio, envelopeBounds!.y + envelopeBounds!.height * yRatio, ); await expect(chamber).toHaveClass(/selected/); await expect( page.locator(".flow-canvas .react-flow__node.selected"), ).toHaveCount(1); } }); test("信号输出连接后隐藏且拒绝第二条连接", async ({ page }) => { await page.goto("/"); const step = await dragSymbolToCanvas(page, "amesim_step0", { x: 260, y: 360, }); const firstForce = await dragSymbolToCanvas(page, "amesim_forc", { x: 620, y: 260, }); const secondForce = await dragSymbolToCanvas(page, "amesim_forc", { x: 620, y: 480, }); const output = step.locator('.port-handle[data-port-name="out"]'); const firstInput = firstForce.locator('.port-handle[data-port-name="res"]'); const secondInput = secondForce.locator('.port-handle[data-port-name="res"]'); await output.click(); await firstInput.click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await expect(output).toHaveAttribute("data-connected", "true"); await expect(output).toHaveCSS("opacity", "0"); await expect(output).toHaveCSS("pointer-events", "none"); await expect(firstInput).toHaveCSS("opacity", "0"); await expect(firstInput).toHaveCSS("pointer-events", "none"); await clickLocatorCenter(page, output); await expectNoActivePortConnection(page); await secondInput.click(); await expect(secondInput).toHaveAttribute("data-connection-state", "origin"); await clickLocatorCenter(page, output); await expectNoActivePortConnection(page); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await expect(output).toHaveCSS("opacity", "0"); await expect(secondInput).toHaveAttribute("data-connected", "false"); await expect(secondInput).toHaveCSS("opacity", "1"); await expect(secondInput).toHaveCSS("pointer-events", "auto"); await dragNodePortNearTarget(page, secondForce, secondInput, output); await page.mouse.up(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await expect(output).toHaveAttribute("data-connected", "true"); await expect(secondInput).toHaveAttribute("data-connected", "false"); await page.getByRole("button", { name: "保存工程", exact: true }).click(); const addedLegacyFanOut = await page.evaluate(() => { const key = "system-simulation-flow:project:demo-system"; const raw = window.localStorage.getItem(key); if (!raw) { return false; } const project = JSON.parse(raw) as { edges?: Array>; }; const firstEdge = project.edges?.[0]; if (!firstEdge || !project.edges) { return false; } project.edges.push({ ...firstEdge, id: "legacy-fan-out-edge", target: "amesim_forc_2", targetHandle: "res", }); window.localStorage.setItem(key, JSON.stringify(project)); return true; }); expect(addedLegacyFanOut).toBe(true); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); await expect(output).toHaveCSS("opacity", "0"); await expect(firstInput).toHaveCSS("opacity", "0"); await expect(secondInput).toHaveCSS("opacity", "1"); await page.getByRole("button", { name: "展开仿真控制台" }).click(); await expect( page.getByRole("complementary", { name: "仿真控制台", exact: true }), ).toContainText( "已丢弃失效连接 legacy-fan-out-edge:当前目录合同不允许端口被多条连接占用", ); await page.getByRole("button", { name: "保存工程", exact: true }).click(); const normalizedEdgeIds = await page.evaluate(() => { const raw = window.localStorage.getItem( "system-simulation-flow:project:demo-system", ); const project = raw ? JSON.parse(raw) : null; return (project?.edges ?? []).map((edge: { id?: string }) => edge.id); }); expect(normalizedEdgeIds).toHaveLength(1); expect(normalizedEdgeIds).not.toContain("legacy-fan-out-edge"); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); await expandSimulationConsole(page); const xml = await page.getByLabel("生成的系统 XML").textContent(); expect(xml?.match(/ { 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(0); await expect(page.locator(".flow-canvas .contact-edge-marker")).toHaveCount(0); await expect( page.getByRole("button", { name: "接触连接", exact: true }), ).toHaveCount(0); await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0); await expect( page.locator(".flow-canvas .react-flow__edge-interaction"), ).toHaveCount(0); const contactEdges = page.locator(".flow-canvas .react-flow__edge"); for (let index = 0; index < (await contactEdges.count()); index += 1) { await expect(contactEdges.nth(index)).not.toHaveClass(/selectable/); await expect(contactEdges.nth(index)).not.toHaveAttribute("tabindex", "0"); await expect( contactEdges.nth(index).locator(".edge-endpoint-blocker"), ).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({ projectSchemaVersion: 1, name: "catalog-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.projectSchemaVersion, hasNodeSize: "width" in (project.nodes?.[0] ?? {}) || "height" in (project.nodes?.[0] ?? {}), } : null; }), ) .toEqual({ position: { x: 200, y: 160 }, 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: 200, y: 160 }); }); 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 node = await dragSymbolToCanvas(page, "amesim_pnch012", { x: 540, y: 360, }); const envelope = node.locator('[data-symbol-envelope="true"]'); const selection = page.locator(".flow-canvas .react-flow__selection"); const [paneBounds, envelopeBounds] = await Promise.all([ pane.boundingBox(), envelope.boundingBox(), ]); expect(paneBounds).not.toBeNull(); expect(envelopeBounds).not.toBeNull(); await page.mouse.click(paneBounds!.x + 8, paneBounds!.y + 8); await expect(node).not.toHaveClass(/selected/); const selectionStart = { x: envelopeBounds!.x - 60, y: envelopeBounds!.y - 18, }; const coveredEnd = { x: envelopeBounds!.x + envelopeBounds!.width / 2, y: envelopeBounds!.y + envelopeBounds!.height / 2, }; const separatedEnd = { x: envelopeBounds!.x - 8, y: envelopeBounds!.y + envelopeBounds!.height / 2, }; await page.mouse.move(selectionStart.x, selectionStart.y); await page.mouse.down(); await page.mouse.move(coveredEnd.x, coveredEnd.y, { steps: 8 }); await expect(selection).toHaveCount(1); await expect(node).toHaveClass(/selected/); await page.mouse.move(separatedEnd.x, separatedEnd.y, { steps: 8 }); const activeSelectionBounds = await selection.boundingBox(); expect(activeSelectionBounds).not.toBeNull(); expect(activeSelectionBounds!.width).toBeGreaterThan(0); expect(activeSelectionBounds!.height).toBeGreaterThan(0); expect(activeSelectionBounds!.x + activeSelectionBounds!.width).toBeLessThan( envelopeBounds!.x - 4, ); await expect(node).not.toHaveClass(/selected/); await expect(node.locator(".port-index-label")).toHaveCount(0); await page.mouse.up(); await expect(selection).toHaveCount(0); await expect(node).not.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).not.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/, ); });