From 09778972a611dfcac77fd6e3ceff3c4afbfe1980 Mon Sep 17 00:00:00 2001 From: ljz <425868052@qq.com> Date: Tue, 4 Aug 2026 12:55:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=AB=AF=E5=8F=A3=E6=A0=87?= =?UTF-8?q?=E5=8F=B7=E4=B8=8EMECMAS21=E5=8A=A8=E6=80=81=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 229 +++++++++++- frontend/src/ComponentSymbol.tsx | 6 +- frontend/src/componentSymbols/mechanical.tsx | 203 ++++++++--- frontend/src/componentSymbols/types.ts | 12 +- frontend/src/styles.css | 22 ++ frontend/tests/e2e/component-symbols.spec.ts | 362 ++++++++++++++++++- frontend/tests/e2e/modeling-actions.spec.ts | 6 + 7 files changed, 780 insertions(+), 60 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b635f86..1ce5a12 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -456,6 +456,13 @@ const NODE_SYMBOL_HEIGHT = 84; const SYMBOL_VIEWBOX_WIDTH = 64; const SYMBOL_VIEWBOX_HEIGHT = 48; const SYMBOL_ENVELOPE_PADDING_PX = 2; +const PORT_INDEX_OUTSET_PX = 18; +const PORT_INDEX_TANGENT_OFFSET_PX = 12; +const PORT_INDEX_LABEL_HEIGHT_PX = 20; +const PORT_INDEX_LABEL_MIN_WIDTH_PX = 20; +const PORT_INDEX_LABEL_CHARACTER_WIDTH_PX = 10; +const PORT_INDEX_LABEL_GAP_PX = 1; +const PORT_INDEX_LABEL_LANE_GAP_PX = 2; const CONTACT_SNAP_RADIUS_PX = 12; const CONTACT_DISCONNECT_DISTANCE_PX = 8; const CONTACT_GEOMETRY_TOLERANCE = 0.5; @@ -1585,6 +1592,13 @@ function SimulationComponentNode({ id, data, selected }: NodeProps { if (!hasDedicatedSymbol) { @@ -1605,7 +1619,7 @@ function SimulationComponentNode({ id, data, selected }: NodeProps { updateNodeInternals(id); @@ -1669,10 +1683,26 @@ function SimulationComponentNode({ id, data, selected }: NodeProps - + ) : null} + {portIndexLabels.map((label) => ( + + ))}
{data.label}
); @@ -1760,6 +1790,199 @@ function transformedPortPlacements( }); } +type TransformedPortPlacement = ReturnType< + typeof transformedPortPlacements +>[number]; + +type PortIndexLabelPlacement = { + order: number; + portName: string; + position: Position; + text: string; + x: number; + y: number; +}; + +function portIndexText(port: PortDefinition, index: number) { + const numericSuffix = port.name.match(/^port_([1-9]\d*)$/)?.[1]; + if (numericSuffix !== undefined) { + const parsed = Number(numericSuffix); + if (Number.isSafeInteger(parsed)) { + return String(parsed); + } + } + return String(index + 1); +} + +function portIndexLabelWidth(text: string) { + return Math.max( + PORT_INDEX_LABEL_MIN_WIDTH_PX, + text.length * PORT_INDEX_LABEL_CHARACTER_WIDTH_PX + 4, + ); +} + +function packPortIndexLabelLane( + labels: PortIndexLabelPlacement[], + position: Position, + axisLength: number, +) { + const verticalEdge = position === Position.Left || position === Position.Right; + const coordinate = (label: PortIndexLabelPlacement) => + verticalEdge ? label.y : label.x; + const extent = (label: PortIndexLabelPlacement) => + verticalEdge ? PORT_INDEX_LABEL_HEIGHT_PX : portIndexLabelWidth(label.text); + const sorted = [...labels].sort( + (first, second) => + coordinate(first) - coordinate(second) || first.order - second.order, + ); + if (sorted.length < 2) { + return sorted; + } + + const packedCoordinates: number[] = []; + for (const [index, label] of sorted.entries()) { + const target = coordinate(label); + if (index === 0) { + packedCoordinates.push(target); + continue; + } + const previous = sorted[index - 1]; + const minimumCoordinate = + packedCoordinates[index - 1] + + extent(previous) / 2 + + extent(label) / 2 + + PORT_INDEX_LABEL_GAP_PX; + packedCoordinates.push(Math.max(target, minimumCoordinate)); + } + + const idealShift = + sorted.reduce( + (sum, label, index) => sum + coordinate(label) - packedCoordinates[index], + 0, + ) / sorted.length; + const minimumShift = extent(sorted[0]) / 2 - packedCoordinates[0]; + const maximumShift = + axisLength - + extent(sorted[sorted.length - 1]) / 2 - + packedCoordinates[packedCoordinates.length - 1]; + const shift = + minimumShift <= maximumShift + ? Math.min(Math.max(idealShift, minimumShift), maximumShift) + : idealShift; + + return sorted.map((label, index) => { + const adjustedCoordinate = packedCoordinates[index] + shift; + return verticalEdge + ? { ...label, y: adjustedCoordinate } + : { ...label, x: adjustedCoordinate }; + }); +} + +function offsetPortIndexLabelOutward( + label: PortIndexLabelPlacement, + distance: number, +) { + if (label.position === Position.Left) { + return { ...label, x: label.x - distance }; + } + if (label.position === Position.Right) { + return { ...label, x: label.x + distance }; + } + if (label.position === Position.Top) { + return { ...label, y: label.y - distance }; + } + return { ...label, y: label.y + distance }; +} + +function spreadPortIndexLabels( + labels: PortIndexLabelPlacement[], + position: Position, + axisLength: number, +) { + if (labels.length === 0) { + return labels; + } + const verticalEdge = position === Position.Left || position === Position.Right; + const coordinate = (label: PortIndexLabelPlacement) => + verticalEdge ? label.y : label.x; + const extent = (label: PortIndexLabelPlacement) => + verticalEdge ? PORT_INDEX_LABEL_HEIGHT_PX : portIndexLabelWidth(label.text); + const normalExtent = (label: PortIndexLabelPlacement) => + verticalEdge ? portIndexLabelWidth(label.text) : PORT_INDEX_LABEL_HEIGHT_PX; + const sorted = [...labels].sort( + (first, second) => + coordinate(first) - coordinate(second) || first.order - second.order, + ); + const requiredSpan = + sorted.reduce((sum, label) => sum + extent(label), 0) + + PORT_INDEX_LABEL_GAP_PX * Math.max(0, sorted.length - 1); + const laneCount = Math.min( + sorted.length, + Math.max(1, Math.ceil(requiredSpan / axisLength)), + ); + const lanes = Array.from( + { length: laneCount }, + () => [] as PortIndexLabelPlacement[], + ); + sorted.forEach((label, index) => lanes[index % laneCount].push(label)); + const laneStep = + Math.max(...sorted.map(normalExtent)) + PORT_INDEX_LABEL_LANE_GAP_PX; + + return lanes.flatMap((lane, laneIndex) => + packPortIndexLabelLane(lane, position, axisLength).map((label) => + offsetPortIndexLabelOutward( + label, + PORT_INDEX_OUTSET_PX + laneIndex * laneStep, + ), + ), + ); +} + +function portIndexLabelPlacements( + ports: PortDefinition[], + placements: TransformedPortPlacement[], + rotation: NodeRotation, +) { + const indexByPortName = new Map( + ports.map((port, index) => [port.name, index] as const), + ); + const labels = placements.map(({ port, position, x, y }, order) => { + const index = indexByPortName.get(port.name) ?? order; + const label: PortIndexLabelPlacement = { + order, + portName: port.name, + position, + text: portIndexText(port, index), + x, + y, + }; + if (position === Position.Left || position === Position.Right) { + label.y -= PORT_INDEX_TANGENT_OFFSET_PX; + } else { + label.x += PORT_INDEX_TANGENT_OFFSET_PX; + } + return label; + }); + const renderedSize = nodeFrameDimensions(rotation); + const spreadByOrder = new Map(); + for (const position of [ + Position.Left, + Position.Right, + Position.Top, + Position.Bottom, + ]) { + const group = labels.filter((label) => label.position === position); + const axisLength = + position === Position.Left || position === Position.Right + ? renderedSize.height + : renderedSize.width; + for (const label of spreadPortIndexLabels(group, position, axisLength)) { + spreadByOrder.set(label.order, label); + } + } + return labels.map((label) => spreadByOrder.get(label.order) ?? label); +} + function rotatePoint(x: number, y: number, rotation: NodeRotation): [number, number] { if (rotation === 90) { return [-y, x]; @@ -5749,6 +5972,7 @@ function FlowWorkbench() { > @@ -6038,6 +6262,7 @@ function FlowWorkbench() { > diff --git a/frontend/src/ComponentSymbol.tsx b/frontend/src/ComponentSymbol.tsx index 530d7ff..703abb0 100644 --- a/frontend/src/ComponentSymbol.tsx +++ b/frontend/src/ComponentSymbol.tsx @@ -26,6 +26,7 @@ import { SymbolSvg } from "./componentSymbols/primitives"; import { AmesimStep0Symbol, AmesimUd00Symbol } from "./componentSymbols/signals"; import { AmesimPnch012Symbol, AmesimPnch023Symbol } from "./componentSymbols/storage"; import type { + ComponentSymbolParameters, ComponentSymbolPortAnchors, ComponentSymbolPresentation, SymbolDefinition, @@ -38,6 +39,7 @@ type ComponentSymbolProps = { symbol: string; surface: ComponentSymbolSurface; className?: string; + parameters?: ComponentSymbolParameters; }; function CylinderSymbol() { @@ -361,11 +363,13 @@ export function ComponentSymbol({ symbol, surface, className, + parameters, }: ComponentSymbolProps) { const resolvedSymbol: RegisteredComponentSymbol = isRegisteredComponentSymbol(symbol) ? symbol : "generic"; const definition = symbolRegistry[resolvedSymbol]; + const SymbolRenderer = definition.render; const rootClassName = [ "component-symbol", `component-symbol-${surface}`, @@ -382,7 +386,7 @@ export function ComponentSymbol({ data-component-symbol={resolvedSymbol} data-symbol-presentation={definition.presentation} > - {definition.render()} + ); } diff --git a/frontend/src/componentSymbols/mechanical.tsx b/frontend/src/componentSymbols/mechanical.tsx index 7bb1ff4..e6bda4b 100644 --- a/frontend/src/componentSymbols/mechanical.tsx +++ b/frontend/src/componentSymbols/mechanical.tsx @@ -1,12 +1,17 @@ +import { useId } from "react"; + import { SYMBOL_ACCENT, SYMBOL_FILL, - SYMBOL_MUTED, SYMBOL_STROKE, SymbolSvg, } from "./primitives"; +import type { ComponentSymbolRendererProps } from "./types"; const MECHANICAL_LINE_WIDTH = 1.8; +const MECMAS21_STROKE = "#00af00"; +const MECMAS21_HATCH = "#66cf66"; +const MECMAS21_FILL = "#ffffff"; const PNRP17_STROKE = "#8b134f"; const PNRP17_FILL = "#cf9db6"; const PNRP17_LIGHT_FILL = "#f5ebf0"; @@ -22,23 +27,24 @@ const mechanicalStroke = { function MechanicalSocket({ side }: { side: "left" | "right" }) { const facingLeft = side === "left"; return ( - - - - - ); -} - -function GuideHatching({ y, upward }: { y: number; upward: boolean }) { - const direction = upward ? -1 : 1; - return ( - - {[14, 22, 30, 38, 46].map((x) => ( - - ))} + + + ); } @@ -100,44 +106,133 @@ export function AmesimForcSymbol() { ); } -export function AmesimMecmas21Symbol() { +export function AmesimMecmas21Symbol({ + parameters, +}: ComponentSymbolRendererProps) { + const frictionEnabled = Number(parameters?.useFriction) === 2; + const stopType = Number(parameters?.stoptype); + const limitsEnabled = stopType === 1 || stopType === 2 || stopType === 3; + const showGuides = frictionEnabled || limitsEnabled; + const variant = frictionEnabled + ? limitsEnabled + ? "friction-limited" + : "friction-unlimited" + : limitsEnabled + ? "plain-limited" + : "plain-unlimited"; + const hatchingId = `mecmas21-hatching-${useId().replace( + /[^a-zA-Z0-9_-]/g, + "", + )}`; + return ( - - - - - - - - + + + + + + + - M - - + {showGuides ? ( + + + {limitsEnabled ? ( + + + + ) : ( + + )} + + ) : null} + {frictionEnabled ? ( + + ) : null} + + + + + + M + + + + + ); } diff --git a/frontend/src/componentSymbols/types.ts b/frontend/src/componentSymbols/types.ts index 4c18291..fd19677 100644 --- a/frontend/src/componentSymbols/types.ts +++ b/frontend/src/componentSymbols/types.ts @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ComponentType } from "react"; export type ComponentSymbolPresentation = "framed" | "bare"; @@ -11,7 +11,15 @@ export type ComponentSymbolPortAnchors = Partial< Record<"left" | "right", readonly ComponentSymbolPortAnchor[]> >; +export type ComponentSymbolParameters = Readonly< + Record +>; + +export type ComponentSymbolRendererProps = { + parameters?: ComponentSymbolParameters; +}; + export type SymbolDefinition = { presentation: ComponentSymbolPresentation; - render: () => ReactNode; + render: ComponentType; }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 95c6ac4..82eeeee 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1637,6 +1637,28 @@ textarea { opacity: 0.18; } +.port-index-label { + position: absolute; + z-index: 4; + min-width: 20px; + height: 20px; + box-sizing: border-box; + padding: 0 2px; + border: 0; + color: #111827; + background: #ffffff; + box-shadow: none; + font-size: 18px; + font-variant-numeric: tabular-nums; + font-weight: 700; + line-height: 20px; + pointer-events: none; + text-align: center; + transform: translate(-50%, -50%); + user-select: none; + white-space: nowrap; +} + .flow-canvas .react-flow__edge-path, .flow-canvas .react-flow__connection-path { stroke: #64748b; diff --git a/frontend/tests/e2e/component-symbols.spec.ts b/frontend/tests/e2e/component-symbols.spec.ts index 30c2534..fccb9c3 100644 --- a/frontend/tests/e2e/component-symbols.spec.ts +++ b/frontend/tests/e2e/component-symbols.spec.ts @@ -96,6 +96,20 @@ function portsForSymbol(modelType: string) { if (modelType === "amesim_step0") { return [signalPort("out", "right", "output", 10)]; } + if (modelType === "amesim_mecmas21") { + return [ + mechanicalPort("port_2", "left", 10), + mechanicalPort("port_1", "right", 20), + ]; + } + if (modelType === "amesim_lmechn1") { + return [ + ...Array.from({ length: 8 }, (_, index) => + mechanicalPort(`port_${index + 1}`, "left", (index + 1) * 10), + ), + mechanicalPort("port_9", "right", 90), + ]; + } if (modelType === "amesim_pnrp17") { return [ physicalPort("port_1", "left", 10), @@ -162,6 +176,103 @@ async function expectSymbolAnchors( } } +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(); @@ -273,7 +384,43 @@ test.beforeEach(async ({ page }) => { order: Object.keys(categoryLabels).indexOf(item.category) + 1, }, ports: portsForSymbol(item.modelType), - parameters: [], + parameters: + item.modelType === "amesim_mecmas21" + ? [ + { + name: "useFriction", + label: "启用摩擦", + quantity: "dimensionless", + unit: "", + default: 1, + minimum: 1, + maximum: 2, + minimumExclusive: false, + editor: "choice", + options: [ + { value: 1, label: "否" }, + { value: 2, label: "是" }, + ], + }, + { + name: "stoptype", + label: "限位类型", + quantity: "dimensionless", + unit: "", + default: 4, + minimum: 1, + maximum: 4, + minimumExclusive: false, + editor: "choice", + options: [ + { value: 1, label: "理想限位" }, + { value: 2, label: "弹性限位" }, + { value: 3, label: "恢复碰撞" }, + { value: 4, label: "无限位" }, + ], + }, + ] + : [], })), }, ], @@ -302,6 +449,136 @@ test("全部已移植 AMESim 模型使用专属图标及正确领域配色", asy } }); +test("MECMAS21 图标按摩擦和限位参数切换四种参考外观", async ({ page }) => { + await page.goto("/"); + + const node = await dragSymbolToCanvas(page, "amesim_mecmas21", { + x: 430, + y: 300, + }); + const symbol = node.locator( + '[data-component-symbol="amesim_mecmas21"]', + ); + const state = symbol.locator("[data-mecmas-variant]"); + const core = symbol.locator('[data-mecmas-part="core"]'); + const envelope = node.locator('[data-symbol-envelope="true"]'); + const useFriction = page.getByLabel("启用摩擦", { exact: true }); + const stoptype = page.getByLabel("限位类型", { exact: true }); + const envelopeHeight = async () => + (await envelope.boundingBox())?.height ?? 0; + + await expectSymbolAnchors(node, [ + { portName: "port_2", x: 2, y: 24 }, + { portName: "port_1", x: 62, y: 24 }, + ]); + + const expectVariant = async ({ + friction, + guides, + limits, + lowerGuide, + variant, + }: { + friction: "disabled" | "enabled"; + guides: boolean; + limits: "finite" | "unlimited"; + lowerGuide: "finite" | "none" | "unlimited"; + variant: string; + }) => { + await expect(state).toHaveAttribute("data-mecmas-variant", variant); + await expect(state).toHaveAttribute("data-mecmas-friction", friction); + await expect(state).toHaveAttribute("data-mecmas-limits", limits); + await expect( + symbol.locator('[data-mecmas-part="friction-markers"]'), + ).toHaveCount(friction === "enabled" ? 1 : 0); + await expect(symbol.locator('[data-mecmas-part="guides"]')).toHaveCount( + guides ? 1 : 0, + ); + await expect( + symbol.locator('[data-mecmas-part="finite-lower-guide"]'), + ).toHaveCount(lowerGuide === "finite" ? 1 : 0); + await expect( + symbol.locator('[data-mecmas-part="unlimited-lower-guide"]'), + ).toHaveCount(lowerGuide === "unlimited" ? 1 : 0); + await expect(symbol.locator("svg")).toHaveAttribute("viewBox", "0 0 64 48"); + }; + + await expectVariant({ + friction: "disabled", + guides: false, + limits: "unlimited", + lowerGuide: "none", + variant: "plain-unlimited", + }); + await expect.poll(envelopeHeight).toBeLessThan(60); + const fixedCore = await core.evaluate((element: SVGGElement) => { + const bounds = element.getBBox(); + return { + bounds: { + height: bounds.height, + width: bounds.width, + x: bounds.x, + y: bounds.y, + }, + markup: element.innerHTML, + }; + }); + const compactEnvelopeHeight = await envelopeHeight(); + + await useFriction.selectOption("2"); + await expectVariant({ + friction: "enabled", + guides: true, + limits: "unlimited", + lowerGuide: "unlimited", + variant: "friction-unlimited", + }); + await expect.poll(envelopeHeight).toBeGreaterThan(compactEnvelopeHeight + 20); + + await stoptype.selectOption("2"); + await expectVariant({ + friction: "enabled", + guides: true, + limits: "finite", + lowerGuide: "finite", + variant: "friction-limited", + }); + + await useFriction.selectOption("1"); + await expectVariant({ + friction: "disabled", + guides: true, + limits: "finite", + lowerGuide: "finite", + variant: "plain-limited", + }); + + await stoptype.selectOption("4"); + await expectVariant({ + friction: "disabled", + guides: false, + limits: "unlimited", + lowerGuide: "none", + variant: "plain-unlimited", + }); + await expect.poll(envelopeHeight).toBeLessThan(compactEnvelopeHeight + 1); + expect(await envelopeHeight()).toBeCloseTo(compactEnvelopeHeight, 1); + expect( + await core.evaluate((element: SVGGElement) => { + const bounds = element.getBBox(); + return { + bounds: { + height: bounds.height, + width: bounds.width, + x: bounds.x, + y: bounds.y, + }, + markup: element.innerHTML, + }; + }), + ).toEqual(fixedCore); +}); + test("PNRP17 图标匹配参考结构并将五个端口放在对应接口", async ({ page }) => { await page.goto("/"); @@ -329,6 +606,89 @@ test("PNRP17 图标匹配参考结构并将五个端口放在对应接口", asyn ).toHaveClass(/react-flow__handle-bottom/); }); +test("选中元件时按接口编号显示固定且不重叠的数字标记", async ({ + page, +}) => { + await page.goto("/"); + + const mecmas = await dragSymbolToCanvas(page, "amesim_mecmas21", { + x: 260, + y: 220, + }); + await expectPortIndexLabels(mecmas, { port_1: "1", port_2: "2" }); + let layout = await portIndexLabelLayout(mecmas); + expect(layout.overlaps).toBe(0); + expect(layout.outwardViolations).toBe(0); + expect(layout.minimumDistance).toBeGreaterThan(18); + expect(layout.maximumDistance).toBeLessThan(52); + + const signalOrifice = await dragSymbolToCanvas(page, "amesim_pnvo001", { + x: 520, + y: 220, + }); + await expect(mecmas.locator(".port-index-label")).toHaveCount(0); + await expectPortIndexLabels(signalOrifice, { + res: "1", + port_2: "2", + port_3: "3", + }); + layout = await portIndexLabelLayout(signalOrifice); + expect(layout.overlaps).toBe(0); + expect(layout.outwardViolations).toBe(0); + expect(layout.maximumDistance).toBeLessThan(52); + + const multiPortNode = await dragSymbolToCanvas(page, "amesim_lmechn1", { + x: 780, + y: 300, + }); + await expect(signalOrifice.locator(".port-index-label")).toHaveCount(0); + await expectPortIndexLabels( + multiPortNode, + Object.fromEntries( + Array.from({ length: 9 }, (_, index) => [ + `port_${index + 1}`, + String(index + 1), + ]), + ), + ); + layout = await portIndexLabelLayout(multiPortNode); + expect(layout.overlaps).toBe(0); + expect(layout.outwardViolations).toBe(0); + expect(layout.minimumDistance).toBeGreaterThan(18); + expect(layout.maximumDistance).toBeLessThan(52); + + await page + .getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true }) + .click(); + await page + .getByRole("button", { name: "水平镜像 (Shift+H)", exact: true }) + .click(); + await expectPortIndexLabels( + multiPortNode, + Object.fromEntries( + Array.from({ length: 9 }, (_, index) => [ + `port_${index + 1}`, + String(index + 1), + ]), + ), + ); + layout = await portIndexLabelLayout(multiPortNode); + expect(layout.overlaps).toBe(0); + expect(layout.outwardViolations).toBe(0); + expect(layout.maximumDistance).toBeLessThan(52); + + const outsideLabelBounds = await multiPortNode + .locator('.port-index-label[data-port-name="port_9"]') + .boundingBox(); + expect(outsideLabelBounds).not.toBeNull(); + await page.mouse.click( + outsideLabelBounds!.x + outsideLabelBounds!.width / 2, + outsideLabelBounds!.y + outsideLabelBounds!.height / 2, + ); + await expect(multiPortNode).not.toHaveClass(/selected/); + await expect(multiPortNode.locator(".port-index-label")).toHaveCount(0); +}); + test("AMESim canvas nodes use icon anchors and highlight only compatible free ports", async ({ page, }) => { diff --git a/frontend/tests/e2e/modeling-actions.spec.ts b/frontend/tests/e2e/modeling-actions.spec.ts index 44077bb..a8f0601 100644 --- a/frontend/tests/e2e/modeling-actions.spec.ts +++ b/frontend/tests/e2e/modeling-actions.spec.ts @@ -20,6 +20,12 @@ test("未知图形元件使用带端口和名称的默认节点,并将动作 await expect(node.locator(".node-symbol-anchor")).toHaveCount(0); await expect(node.locator(".node-label")).toHaveText("generic_sensor_1"); await expect(node.locator(".port-handle")).toHaveCount(2); + await expect( + node.locator('.port-index-label[data-port-name="port_a"]'), + ).toHaveText("1"); + await expect( + node.locator('.port-index-label[data-port-name="port_b"]'), + ).toHaveText("2"); const consolePanel = page.getByRole("complementary", { name: "仿真控制台",