完善建模交互、组件图标与系统协议

This commit is contained in:
ljz committed 2026-08-15 17:40:18 +08:00
1 parent 456c29b3b6
commit 6572defaa4
66 files changed
+10067 -4163

No files matched your search

+1952 -1234
View File
File diff suppressed because it is too large. Load diff
+83 -73
View File
@@ -10,7 +10,11 @@ import {
AmesimForcSymbol,
FORC_PORT_ANCHORS,
AmesimLmechn1Symbol,
lmechn1RightPortCount,
lmechn1SymbolMetrics,
LSTP00A_PORT_ANCHORS,
MECMAS21_PORT_ANCHORS,
PNRP17_PORT_ANCHORS,
AmesimPnrp17Symbol,
AmesimLstp00aSymbol,
AmesimMecmas21Symbol,
@@ -29,6 +33,9 @@ import {
AmesimPnor001Symbol,
AmesimPnvo001FixedSymbol,
AmesimPnvo001SignalSymbol,
P4NODE2_PORT_ANCHORS,
PIPE_NETWORK_PORT_ANCHORS,
PN3NODE2_PORT_ANCHORS,
PNVO001_PORT_ANCHORS,
} from "./componentSymbols/pneumatic";
import {
@@ -126,18 +133,6 @@ function sameViewBox(
);
}
const LEGACY_HORIZONTAL_PORT_ANCHORS: ComponentSymbolPortAnchors = {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
};
const LEGACY_PNVO001_SIGNAL_PORT_ANCHORS: ComponentSymbolPortAnchors = {
left: [
{ x: 26, y: 46 },
{ x: 2, y: 24 },
],
right: [{ x: 62, y: 24 }],
};
type CustomSymbolLayout = {
viewBox?: ComponentSymbolViewBox;
symbolSize: ComponentSymbolSize;
@@ -186,6 +181,30 @@ function makePortlessSymbolLayout(
};
}
function lmechn1CanvasLayout(
parameters?: ComponentSymbolParameters,
): ComponentSymbolLayout {
const metrics = lmechn1SymbolMetrics(parameters);
const symbolHeight = metrics.viewBoxHeight * 2;
return makeSymbolLayout(
"custom",
{
left: [{ x: 2, y: metrics.viewBoxHeight / 2 }],
right: metrics.rightPortRows.map((y) => ({ x: 62, y })),
},
{
viewBox: {
x: 0,
y: 0,
width: 64,
height: metrics.viewBoxHeight,
},
symbolSize: { width: 128, height: symbolHeight },
nodeSize: { width: 148, height: symbolHeight },
},
);
}
function CylinderSymbol() {
return (
<SymbolSvg>
@@ -377,7 +396,6 @@ const symbolRegistry = {
render: AmesimPnpl01Symbol,
layout: {
...makeSymbolLayout("compact", { left: [PNPL01_PORT_ANCHOR] }),
legacyPortAnchors: { left: [{ x: 2, y: 24 }] },
},
},
amesim_pnch023: {
@@ -388,10 +406,6 @@ const symbolRegistry = {
left: [PNCH023_PORT_ANCHORS.left],
right: [PNCH023_PORT_ANCHORS.right],
}),
legacyPortAnchors: {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
},
},
},
amesim_pnch012: {
@@ -409,16 +423,6 @@ const symbolRegistry = {
PNCH012_PORT_ANCHORS.bottom,
],
}),
legacyPortAnchors: {
left: [
{ x: 2, y: 16 },
{ x: 2, y: 32 },
],
right: [
{ x: 62, y: 16 },
{ x: 62, y: 32 },
],
},
},
},
amesim_pnor001: {
@@ -429,7 +433,6 @@ const symbolRegistry = {
left: [PNVO001_PORT_ANCHORS.physicalLeft],
right: [PNVO001_PORT_ANCHORS.physicalRight],
}),
legacyPortAnchors: LEGACY_HORIZONTAL_PORT_ANCHORS,
},
},
amesim_pnvo001_fixed: {
@@ -440,7 +443,6 @@ const symbolRegistry = {
left: [PNVO001_PORT_ANCHORS.physicalLeft],
right: [PNVO001_PORT_ANCHORS.physicalRight],
}),
legacyPortAnchors: LEGACY_HORIZONTAL_PORT_ANCHORS,
},
},
amesim_pnvo001: {
@@ -454,49 +456,50 @@ const symbolRegistry = {
],
right: [PNVO001_PORT_ANCHORS.physicalRight],
}),
legacyPortAnchors: LEGACY_PNVO001_SIGNAL_PORT_ANCHORS,
},
},
amesim_pnl00r: {
presentation: "framed",
render: AmesimPnl00rSymbol,
layout: makeSymbolLayout("compact", {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
layout: makeSymbolLayout("standard", {
left: [PIPE_NETWORK_PORT_ANCHORS.left],
right: [PIPE_NETWORK_PORT_ANCHORS.right],
}),
},
amesim_pnl0001: {
presentation: "framed",
render: AmesimPnl0001Symbol,
layout: makeSymbolLayout("small", {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
layout: makeSymbolLayout("standard", {
left: [PIPE_NETWORK_PORT_ANCHORS.left],
right: [PIPE_NETWORK_PORT_ANCHORS.right],
}),
},
amesim_pnl0002: {
presentation: "framed",
render: AmesimPnl0002Symbol,
layout: makeSymbolLayout("small", {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
layout: makeSymbolLayout("standard", {
left: [PIPE_NETWORK_PORT_ANCHORS.left],
right: [PIPE_NETWORK_PORT_ANCHORS.right],
}),
},
amesim_pnl0003: {
presentation: "framed",
render: AmesimPnl0003Symbol,
layout: makeSymbolLayout("small", {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
layout: makeSymbolLayout("standard", {
left: [PIPE_NETWORK_PORT_ANCHORS.left],
right: [PIPE_NETWORK_PORT_ANCHORS.right],
}),
},
amesim_pn3node2: {
presentation: "framed",
render: AmesimPn3Node2Symbol,
layout: makeSymbolLayout("compact", {
left: [{ x: 3, y: 24 }],
layout: makeSymbolLayout("small", {
// 目录中 port_1 属于 left 组,但参考图把它显示在顶部;
// port_2 是粗线汇流支路,固定显示在左侧。
left: [PN3NODE2_PORT_ANCHORS.port1],
right: [
{ x: 61, y: 12 },
{ x: 61, y: 36 },
PN3NODE2_PORT_ANCHORS.port2,
PN3NODE2_PORT_ANCHORS.port3,
],
}),
},
@@ -504,11 +507,11 @@ const symbolRegistry = {
presentation: "framed",
render: AmesimP4Node2Symbol,
layout: makeSymbolLayout("small", {
left: [{ x: 3, y: 24 }],
left: [P4NODE2_PORT_ANCHORS.port1],
right: [
{ x: 61, y: 8 },
{ x: 61, y: 24 },
{ x: 61, y: 40 },
P4NODE2_PORT_ANCHORS.port2,
P4NODE2_PORT_ANCHORS.port3,
P4NODE2_PORT_ANCHORS.port4,
],
}),
},
@@ -517,7 +520,6 @@ const symbolRegistry = {
render: AmesimStep0Symbol,
layout: {
...makeSymbolLayout("standard", { right: [SIGNAL_OUTPUT_PORT_ANCHOR] }),
legacyPortAnchors: { right: [{ x: 60, y: 24 }] },
},
},
amesim_ud00: {
@@ -525,7 +527,6 @@ const symbolRegistry = {
render: AmesimUd00Symbol,
layout: {
...makeSymbolLayout("standard", { right: [SIGNAL_OUTPUT_PORT_ANCHOR] }),
legacyPortAnchors: { right: [{ x: 60, y: 24 }] },
},
},
amesim_f000: {
@@ -533,7 +534,6 @@ const symbolRegistry = {
render: AmesimF000Symbol,
layout: {
...makeSymbolLayout("compact", { right: [F000_PORT_ANCHOR] }),
legacyPortAnchors: { right: [{ x: 62, y: 24 }] },
},
},
amesim_forc: {
@@ -544,7 +544,6 @@ const symbolRegistry = {
left: [FORC_PORT_ANCHORS.left],
right: [FORC_PORT_ANCHORS.right],
}),
legacyPortAnchors: LEGACY_HORIZONTAL_PORT_ANCHORS,
},
},
amesim_mecmas21: {
@@ -555,18 +554,19 @@ const symbolRegistry = {
left: [MECMAS21_PORT_ANCHORS.left],
right: [MECMAS21_PORT_ANCHORS.right],
}),
legacyPortAnchors: LEGACY_HORIZONTAL_PORT_ANCHORS,
},
variantKey: (parameters) =>
`${parameters?.useFriction ?? ""}:${parameters?.stoptype ?? ""}`,
`${parameters?.useFriction ?? 2}:${parameters?.stoptype ?? 1}`,
},
amesim_lstp00a: {
presentation: "framed",
render: AmesimLstp00aSymbol,
layout: makeSymbolLayout("standard", {
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
}),
layout: {
...makeSymbolLayout("standard", {
left: [LSTP00A_PORT_ANCHORS.left],
right: [LSTP00A_PORT_ANCHORS.right],
}),
},
},
amesim_lmechn1: {
presentation: "framed",
@@ -574,14 +574,16 @@ const symbolRegistry = {
layout: makeSymbolLayout(
"custom",
{
left: [7, 12, 17, 22, 27, 32, 37, 42].map((y) => ({ x: 5, y })),
right: [{ x: 59, y: 24 }],
left: [{ x: 2, y: 24 }],
right: [{ x: 62, y: 24 }],
},
{
symbolSize: { width: 128, height: 128 },
nodeSize: { width: 148, height: 128 },
},
),
layoutForParameters: lmechn1CanvasLayout,
variantKey: (parameters) => String(lmechn1RightPortCount(parameters)),
},
amesim_pnrp17: {
presentation: "framed",
@@ -589,13 +591,13 @@ const symbolRegistry = {
// PNRP17 的当前 112×112 画布作为 standard 档位基准。
layout: makeSymbolLayout("standard", {
left: [
{ x: 25.5, y: 48 },
{ x: 2, y: 4.4 },
{ x: 2, y: 24.1 },
PNRP17_PORT_ANCHORS.bottom,
PNRP17_PORT_ANCHORS.upperLeft,
PNRP17_PORT_ANCHORS.middleLeft,
],
right: [
{ x: 62, y: 4.4 },
{ x: 62, y: 24.1 },
PNRP17_PORT_ANCHORS.upperRight,
PNRP17_PORT_ANCHORS.middleRight,
],
}),
},
@@ -635,10 +637,15 @@ export function componentSymbolPresentation(
: "framed";
}
export function componentSymbolLayout(symbol: string): ComponentSymbolLayout {
return isRegisteredComponentSymbol(symbol)
? symbolRegistry[symbol].layout
: fallbackComponentSymbolLayout;
export function componentSymbolLayout(
symbol: string,
parameters?: ComponentSymbolParameters,
): ComponentSymbolLayout {
if (!isRegisteredComponentSymbol(symbol)) {
return fallbackComponentSymbolLayout;
}
const definition: SymbolDefinition = symbolRegistry[symbol];
return definition.layoutForParameters?.(parameters) ?? definition.layout;
}
export function componentSymbolPortAnchors(
@@ -669,7 +676,10 @@ export function ComponentSymbol({
: "generic";
const definition: SymbolDefinition = symbolRegistry[resolvedSymbol];
const SymbolRenderer = definition.render;
const { layout } = definition;
const layout =
surface === "canvas"
? definition.layoutForParameters?.(parameters) ?? definition.layout
: definition.layout;
const rootRef = useRef<HTMLSpanElement>(null);
const [paletteViewBox, setPaletteViewBox] = useState<ComponentSymbolViewBox>(
layout.viewBox,
@@ -737,7 +747,7 @@ export function ComponentSymbol({
ref={rootRef}
>
<SymbolViewBoxProvider viewBox={renderedViewBox}>
<SymbolRenderer parameters={parameters} />
<SymbolRenderer parameters={parameters} surface={surface} />
</SymbolViewBoxProvider>
</span>
);
+4 -28
View File
@@ -6,11 +6,9 @@ import {
} from "@xyflow/react";
export const CONTACT_AWARE_EDGE_TYPE = "contact-aware";
// React Flow 在 Loose 模式下会把重叠 Handle 的边端点分别放在圆点两侧,
// 因此两个端口中心完全重合时,edge props 仍会相差约一个 Handle 直径。
export const CONTACT_EDGE_SCREEN_TOLERANCE = 12;
export function ContactAwareEdge({
data,
id,
interactionWidth,
markerEnd,
@@ -23,31 +21,9 @@ export function ContactAwareEdge({
targetX,
targetY,
}: EdgeProps) {
const distance = Math.hypot(targetX - sourceX, targetY - sourceY);
if (distance <= CONTACT_EDGE_SCREEN_TOLERANCE) {
const centerX = (sourceX + targetX) / 2;
const centerY = (sourceY + targetY) / 2;
return (
<>
<circle
aria-label="接触连接"
className="contact-edge-hit"
cx={centerX}
cy={centerY}
data-contact-edge-id={id}
r={12}
role="button"
tabIndex={0}
/>
<circle
aria-hidden="true"
className="contact-edge-marker"
cx={centerX}
cy={centerY}
r={3.5}
/>
</>
);
const isContactEdge = data?.isContactEdge === true;
if (isContactEdge) {
return null;
}
const [edgePath] = getSmoothStepPath({
+377
View File
@@ -0,0 +1,377 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import {
ChevronDown,
ChevronUp,
Square,
SquareTerminal,
Trash2,
} from "lucide-react";
type DockedConsoleMode = "minimized" | "normal" | "maximized";
type DockedConsoleLevel = "info" | "success" | "warning" | "error";
type DockedConsoleEntry = {
id: number;
timestamp: string;
level: DockedConsoleLevel;
message: string;
format: "message" | "xml";
};
type DockedConsoleProgress = {
state: "idle" | "running" | "success" | "warning" | "error";
percent: number;
message: string;
startTime: number;
simulatedTime: number;
totalTime: number;
};
type DockedSimulationConsoleProps = {
entries: DockedConsoleEntry[];
mode: DockedConsoleMode;
progress: DockedConsoleProgress;
onClear: () => void;
onModeChange: (mode: DockedConsoleMode) => void;
onStop: () => void;
stopPending: boolean;
};
type ResizeSession = {
pointerId: number;
startHeight: number;
startY: number;
};
const CONSOLE_DOCK_HEIGHT_KEY =
"system-simulation-flow:modeling-console-dock-height";
const DEFAULT_CONSOLE_DOCK_HEIGHT = 180;
const MIN_CONSOLE_DOCK_HEIGHT = 120;
const MAX_CONSOLE_DOCK_HEIGHT = 420;
const MIN_MODELING_CANVAS_HEIGHT = 240;
const COLLAPSED_CONSOLE_DOCK_HEIGHT = 40;
const consoleLevelLabels: Record<DockedConsoleLevel, string> = {
info: "信息",
success: "完成",
warning: "警告",
error: "错误",
};
function simulationProgressClockText(progress: DockedConsoleProgress) {
const simulatedTime = Number.isFinite(progress.simulatedTime)
? progress.simulatedTime
: progress.startTime;
const totalTime = Number.isFinite(progress.totalTime)
? progress.totalTime
: progress.startTime;
return `${simulatedTime.toFixed(2)} s / ${totalTime.toFixed(2)} s`;
}
function maximumConsoleDockHeight(hostHeight = window.innerHeight) {
return Math.max(
MIN_CONSOLE_DOCK_HEIGHT,
Math.min(
MAX_CONSOLE_DOCK_HEIGHT,
Math.round(hostHeight * 0.48),
Math.max(MIN_CONSOLE_DOCK_HEIGHT, hostHeight - MIN_MODELING_CANVAS_HEIGHT),
),
);
}
function clampConsoleDockHeight(height: number, hostHeight = window.innerHeight) {
return Math.min(
maximumConsoleDockHeight(hostHeight),
Math.max(MIN_CONSOLE_DOCK_HEIGHT, Math.round(height)),
);
}
function loadConsoleDockHeight() {
try {
const stored = Number(localStorage.getItem(CONSOLE_DOCK_HEIGHT_KEY));
return Number.isFinite(stored) && stored > 0
? clampConsoleDockHeight(stored)
: DEFAULT_CONSOLE_DOCK_HEIGHT;
} catch {
return DEFAULT_CONSOLE_DOCK_HEIGHT;
}
}
function storeConsoleDockHeight(height: number) {
try {
localStorage.setItem(CONSOLE_DOCK_HEIGHT_KEY, String(height));
} catch {
// 本地存储不可用时仍可在当前页面调整高度。
}
}
function collapsedSummary(
entries: DockedConsoleEntry[],
progress: DockedConsoleProgress,
) {
if (progress.state === "running") {
return `${progress.message} · ${simulationProgressClockText(progress)} · ${progress.percent}%`;
}
const latestEntry = entries.at(-1);
if (!latestEntry) {
return progress.message;
}
return latestEntry.format === "xml"
? "系统 XML 已生成"
: latestEntry.message.replace(/\s+/g, " ").trim();
}
export function DockedSimulationConsole({
entries,
mode,
progress,
onClear,
onModeChange,
onStop,
stopPending,
}: DockedSimulationConsoleProps) {
const dockRef = useRef<HTMLElement>(null);
const logRef = useRef<HTMLDivElement>(null);
const resizeSessionRef = useRef<ResizeSession | null>(null);
const [height, setHeight] = useState(loadConsoleDockHeight);
const [hostHeight, setHostHeight] = useState(() => window.innerHeight);
const [resizing, setResizing] = useState(false);
const collapsed = mode === "minimized";
const canStop = progress.state === "running" && !stopPending;
const summary = useMemo(
() => collapsedSummary(entries, progress),
[entries, progress],
);
useEffect(() => {
if (!collapsed && logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [collapsed, entries]);
useEffect(() => {
if (!collapsed) {
storeConsoleDockHeight(height);
}
}, [collapsed, height]);
useEffect(() => {
const host = dockRef.current?.parentElement;
const updateHostHeight = () => {
const nextHostHeight = host?.clientHeight ?? window.innerHeight;
setHostHeight(nextHostHeight);
setHeight((current) => clampConsoleDockHeight(current, nextHostHeight));
};
updateHostHeight();
const observer =
host && typeof ResizeObserver !== "undefined"
? new ResizeObserver(updateHostHeight)
: null;
if (host) {
observer?.observe(host);
}
window.addEventListener("resize", updateHostHeight);
return () => {
observer?.disconnect();
window.removeEventListener("resize", updateHostHeight);
};
}, []);
const beginResize = (event: ReactPointerEvent<HTMLDivElement>) => {
if (collapsed || event.button !== 0) {
return;
}
event.preventDefault();
resizeSessionRef.current = {
pointerId: event.pointerId,
startHeight: height,
startY: event.clientY,
};
event.currentTarget.setPointerCapture(event.pointerId);
setResizing(true);
};
const resize = (event: ReactPointerEvent<HTMLDivElement>) => {
const session = resizeSessionRef.current;
if (!session || session.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
setHeight(
clampConsoleDockHeight(
session.startHeight + session.startY - event.clientY,
hostHeight,
),
);
};
const finishResize = (event: ReactPointerEvent<HTMLDivElement>) => {
const session = resizeSessionRef.current;
if (!session || session.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
resizeSessionRef.current = null;
setResizing(false);
};
const consoleStyle = {
"--simulation-console-dock-height": `${
collapsed ? COLLAPSED_CONSOLE_DOCK_HEIGHT : height
}px`,
} as CSSProperties;
return (
<aside
aria-label="仿真控制台"
className={`simulation-console-dock ${collapsed ? "minimized" : "normal"} ${
resizing ? "resizing" : ""
}`}
data-editor-shortcuts-ignore="true"
ref={dockRef}
style={consoleStyle}
>
{!collapsed ? (
<div
aria-label="调整仿真控制台高度"
aria-orientation="horizontal"
aria-valuemax={maximumConsoleDockHeight(hostHeight)}
aria-valuemin={MIN_CONSOLE_DOCK_HEIGHT}
aria-valuenow={height}
className="simulation-console-dock-resizer"
onDoubleClick={() => setHeight(DEFAULT_CONSOLE_DOCK_HEIGHT)}
onKeyDown={(event) => {
const nextHeight =
event.key === "ArrowUp"
? height + 20
: event.key === "ArrowDown"
? height - 20
: event.key === "Home"
? MIN_CONSOLE_DOCK_HEIGHT
: event.key === "End"
? maximumConsoleDockHeight(hostHeight)
: null;
if (nextHeight === null) {
return;
}
event.preventDefault();
setHeight(clampConsoleDockHeight(nextHeight, hostHeight));
}}
onLostPointerCapture={finishResize}
onPointerCancel={finishResize}
onPointerDown={beginResize}
onPointerMove={resize}
onPointerUp={finishResize}
role="separator"
tabIndex={0}
title="拖动调整控制台高度,双击恢复默认高度"
/>
) : null}
<header className="simulation-console-dock-header">
<div className="simulation-console-dock-title">
<SquareTerminal aria-hidden="true" size={17} />
<strong>仿真控制台</strong>
<span className="simulation-console-dock-summary" title={summary}>
{summary}
</span>
</div>
<div className="simulation-console-dock-actions">
<button
aria-label={stopPending ? "正在停止仿真" : "停止仿真"}
className={canStop ? "active" : ""}
disabled={!canStop}
onClick={onStop}
title={canStop ? "停止当前仿真" : "当前没有可停止的仿真"}
type="button"
>
<Square aria-hidden="true" fill="currentColor" size={13} strokeWidth={1.8} />
</button>
{!collapsed ? (
<button
aria-label="清空控制台"
disabled={entries.length === 0}
onClick={onClear}
title="清空控制台"
type="button"
>
<Trash2 aria-hidden="true" size={16} />
</button>
) : null}
<button
aria-label={collapsed ? "展开仿真控制台" : "收起仿真控制台"}
onClick={() => onModeChange(collapsed ? "normal" : "minimized")}
title={collapsed ? "展开仿真控制台" : "收起仿真控制台"}
type="button"
>
{collapsed ? (
<ChevronUp aria-hidden="true" size={17} />
) : (
<ChevronDown aria-hidden="true" size={17} />
)}
</button>
</div>
</header>
{!collapsed ? (
<>
<div className={`simulation-console-dock-progress ${progress.state}`}>
<div className="simulation-console-dock-progress-label">
<span>{progress.message}</span>
<span>{simulationProgressClockText(progress)}</span>
<strong>{progress.percent}%</strong>
</div>
<div
aria-label={`仿真进度 ${progress.percent}%,${simulationProgressClockText(progress)}`}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progress.percent}
className="simulation-console-dock-progress-track"
role="progressbar"
>
<span style={{ width: `${progress.percent}%` }} />
</div>
</div>
<div
aria-label="控制台日志"
className="simulation-console-dock-log"
ref={logRef}
role="region"
>
{entries.length === 0 ? (
<div className="simulation-console-dock-empty">暂无仿真消息</div>
) : (
entries.map((entry) => (
<div
className={`simulation-console-dock-entry ${entry.level} ${entry.format}`}
data-entry-id={entry.id}
key={entry.id}
>
<time>{entry.timestamp}</time>
<span className="simulation-console-dock-entry-level">
{consoleLevelLabels[entry.level]}
</span>
{entry.format === "xml" ? (
<pre
aria-label="生成的系统 XML"
className="simulation-console-dock-entry-xml"
>
{entry.message}
</pre>
) : (
<span className="simulation-console-dock-entry-message">
{entry.message}
</span>
)}
</div>
))
)}
</div>
</>
) : null}
</aside>
);
}
+41 -9
View File
@@ -77,8 +77,11 @@ type ResultProjectEdge = {
id: string;
source: string;
target: string;
sourceHandle: string | null;
targetHandle: string | null;
sourceHandle: string;
targetHandle: string;
data: {
isContactEdge: boolean;
};
};
type ResultSimulationConfig = {
@@ -131,8 +134,8 @@ export type SimulationResultsSnapshot = {
id: string;
createdAt: string;
project: {
projectSchemaVersion: 1;
name: string;
presentationLayoutVersion?: 1 | null;
nodes: ResultProjectNode[];
edges: ResultProjectEdge[];
simulation: ResultSimulationConfig;
@@ -384,7 +387,18 @@ export function SimulationResultsView({
};
clampLayoutToWorkbench();
window.addEventListener("resize", clampLayoutToWorkbench);
return () => window.removeEventListener("resize", clampLayoutToWorkbench);
const workbench = workbenchRef.current;
const observer =
workbench && typeof ResizeObserver !== "undefined"
? new ResizeObserver(clampLayoutToWorkbench)
: null;
if (workbench) {
observer?.observe(workbench);
}
return () => {
window.removeEventListener("resize", clampLayoutToWorkbench);
observer?.disconnect();
};
}, []);
useEffect(() => {
@@ -1138,6 +1152,7 @@ export function SimulationResultsView({
nodes={readOnlyNodes}
nodesConnectable={false}
nodesDraggable={false}
proOptions={{ hideAttribution: true }}
nodesFocusable
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
onPaneClick={() => setSelectedNodeId(null)}
@@ -3910,10 +3925,8 @@ function isSimulationResultsSnapshotValue(
const result = value.result;
if (
!isRecord(project) ||
project.projectSchemaVersion !== 1 ||
typeof project.name !== "string" ||
(project.presentationLayoutVersion !== undefined &&
project.presentationLayoutVersion !== null &&
project.presentationLayoutVersion !== 1) ||
!Array.isArray(project.nodes) ||
!project.nodes.every(isResultProjectNode) ||
!Array.isArray(project.edges) ||
@@ -3954,6 +3967,7 @@ function isResultProjectNode(value: unknown): value is ResultProjectNode {
typeof data.componentType === "string" &&
typeof data.modelType === "string" &&
Array.isArray(data.ports) &&
data.ports.every(isResultPortDefinition) &&
isRecord(data.parameters) &&
(data.parameterUnits === undefined || isRecord(data.parameterUnits)) &&
[0, 90, 180, 270].includes(Number(data.rotation)) &&
@@ -3961,14 +3975,32 @@ function isResultProjectNode(value: unknown): value is ResultProjectNode {
);
}
function isResultPortDefinition(value: unknown): value is ResultPortDefinition {
return (
isRecord(value) &&
typeof value.name === "string" &&
value.name.length > 0 &&
typeof value.kind === "string" &&
typeof value.domain === "string" &&
typeof value.nominalRole === "string" &&
(value.positiveFlowDirection === undefined ||
typeof value.positiveFlowDirection === "string") &&
(value.side === "left" || value.side === "right")
);
}
function isResultProjectEdge(value: unknown): value is ResultProjectEdge {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.source === "string" &&
typeof value.target === "string" &&
(value.sourceHandle === null || typeof value.sourceHandle === "string") &&
(value.targetHandle === null || typeof value.targetHandle === "string")
typeof value.sourceHandle === "string" &&
value.sourceHandle.length > 0 &&
typeof value.targetHandle === "string" &&
value.targetHandle.length > 0 &&
isRecord(value.data) &&
typeof value.data.isContactEdge === "boolean"
);
}
+337 -128
View File
@@ -63,12 +63,44 @@ export const MECMAS21_PORT_ANCHORS = {
y: 24,
},
} as const;
const LSTP00A_STROKE = "#00af00";
const LSTP00A_STROKE_WIDTH = 3;
const LSTP00A_CONTENT_SCALE = (64 * 0.65) / 60;
const LSTP00A_CONTENT_CENTER = { x: 32, y: 24 } as const;
const LSTP00A_CONTENT_TRANSFORM = `translate(${LSTP00A_CONTENT_CENTER.x} ${LSTP00A_CONTENT_CENTER.y}) scale(${LSTP00A_CONTENT_SCALE}) translate(${-LSTP00A_CONTENT_CENTER.x} ${-LSTP00A_CONTENT_CENTER.y})`;
export const LSTP00A_PORT_ANCHORS = {
left: {
x:
LSTP00A_CONTENT_CENTER.x +
(2 - LSTP00A_CONTENT_CENTER.x) * LSTP00A_CONTENT_SCALE,
y: LSTP00A_CONTENT_CENTER.y,
},
right: {
x:
LSTP00A_CONTENT_CENTER.x +
(62 - LSTP00A_CONTENT_CENTER.x) * LSTP00A_CONTENT_SCALE,
y: LSTP00A_CONTENT_CENTER.y,
},
} as const;
const PNRP17_STROKE = "#8b134f";
const PNRP17_FILL = "#cf9db6";
const PNRP17_LIGHT_FILL = "#f5ebf0";
// PNRP17 使用最终像素线宽;几何缩放不再改变参考图中的粗细层级。
const PNRP17_PRIMARY_STROKE_WIDTH = 2;
const PNRP17_DETAIL_STROKE_WIDTH = 1.5;
const PNRP17_PRIMARY_STROKE_WIDTH = 2.1;
const PNRP17_DETAIL_STROKE_WIDTH = 1.9;
const PNRP17_VERTICAL_CENTER = 24.5;
const PNRP17_VERTICAL_SCALE = 1.21;
const PNRP17_CONTENT_TRANSFORM = `translate(0 ${PNRP17_VERTICAL_CENTER}) scale(1 ${PNRP17_VERTICAL_SCALE}) translate(0 ${-PNRP17_VERTICAL_CENTER})`;
const pnrp17ScaledY = (y: number) =>
PNRP17_VERTICAL_CENTER +
(y - PNRP17_VERTICAL_CENTER) * PNRP17_VERTICAL_SCALE;
export const PNRP17_PORT_ANCHORS = {
bottom: { x: 25.5, y: pnrp17ScaledY(48) },
upperLeft: { x: 2, y: pnrp17ScaledY(4.4) },
middleLeft: { x: 2, y: pnrp17ScaledY(24.1) },
upperRight: { x: 62, y: pnrp17ScaledY(4.4) },
middleRight: { x: 62, y: pnrp17ScaledY(24.1) },
} as const;
const mechanicalStroke = {
fill: "none",
@@ -200,8 +232,8 @@ export function AmesimForcSymbol() {
export function AmesimMecmas21Symbol({
parameters,
}: ComponentSymbolRendererProps) {
const frictionEnabled = Number(parameters?.useFriction) === 2;
const stopType = Number(parameters?.stoptype);
const frictionEnabled = Number(parameters?.useFriction ?? 2) === 2;
const stopType = Number(parameters?.stoptype ?? 1);
const limitsEnabled = stopType === 1 || stopType === 2 || stopType === 3;
const showGuides = frictionEnabled || limitsEnabled;
const variant = frictionEnabled
@@ -342,159 +374,336 @@ export function AmesimMecmas21Symbol({
export function AmesimLstp00aSymbol() {
return (
<SymbolSvg className="component-symbol-amesim-lstp00a" domain="mechanical">
<path d="M2 24h8M54 24h8" {...mechanicalStroke} />
<g transform="rotate(90 32 24)">
<path d="M32 2v7M32 39v7" {...mechanicalStroke} />
<path d="M27 9V5h10v4M27 39v4h10v-4" {...mechanicalStroke} />
<rect
fill={SYMBOL_FILL}
height="3"
stroke={SYMBOL_STROKE}
strokeWidth="1.6"
width="46"
x="9"
y="10"
/>
<rect
fill={SYMBOL_FILL}
height="3"
stroke={SYMBOL_STROKE}
strokeWidth="1.6"
width="46"
x="9"
y="31"
/>
<rect
fill={SYMBOL_FILL}
height="3"
stroke={SYMBOL_STROKE}
strokeWidth="1.6"
width="46"
x="9"
y="39"
<g
data-lstp00a-part="symbol"
fill="#ffffff"
stroke={LSTP00A_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth={LSTP00A_STROKE_WIDTH}
transform={LSTP00A_CONTENT_TRANSFORM}
>
<path
d="M2 24H7.5"
data-lstp00a-part="left-extension"
fill="none"
/>
<path
d="M14 13v4c0 2 12 2 12 4s-12 2-12 4 12 2 12 6"
d="M56.5 24H62"
data-lstp00a-part="right-extension"
fill="none"
/>
<path
data-lstp00a-part="left-socket"
d="M13.75 19.75H6.25V28.25H13.75"
fill="none"
/>
<path
data-lstp00a-part="right-socket"
d="M49.75 19.75H57.25V28.25H49.75"
fill="none"
/>
<path
d="M28.75 17.2C31.2 16 32.6 0 35 0c1.4 0 2.2 17.2 3.7 17.2 2.8 0 3.8-16 7.3-16"
data-lstp00a-part="spring"
fill="none"
stroke={SYMBOL_ACCENT}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<path d="M36 18v-5M36 18h14v6H36v7M40 22h6" {...mechanicalStroke} />
<path
d="M28.75 39H36M41 39H46M36 31V47M33.75 27H41V50.5H33.75"
data-lstp00a-part="lower-link"
fill="none"
/>
<rect
data-lstp00a-part="left-rail"
height="53"
width="3.75"
x="13.75"
y="-2.5"
/>
<rect
data-lstp00a-part="middle-rail"
height="53"
width="3.75"
x="25"
y="-2.5"
/>
<rect
data-lstp00a-part="right-rail"
height="53"
width="3.75"
x="46"
y="-2.5"
/>
</g>
</SymbolSvg>
);
}
export function AmesimLmechn1Symbol() {
const leftPortRows = [7, 12, 17, 22, 27, 32, 37, 42];
export const LMECHN1_DEFAULT_RIGHT_PORT_COUNT = 2;
export const LMECHN1_MAX_RIGHT_PORT_COUNT = 20;
const LMECHN1_PALETTE_RIGHT_PORT_COUNT = 5;
const LMECHN1_PORT_ROW_SPACING = 13;
const LMECHN1_BODY_LEFT = 18;
const LMECHN1_BODY_RIGHT = 46;
const LMECHN1_SOCKET_WIDTH = 6;
const LMECHN1_SOCKET_HALF_HEIGHT = 2.5;
const LMECHN1_LEFT_SOCKET_OUTER_X = LMECHN1_BODY_LEFT - LMECHN1_SOCKET_WIDTH;
const LMECHN1_RIGHT_SOCKET_OUTER_X = LMECHN1_BODY_RIGHT + LMECHN1_SOCKET_WIDTH;
export function lmechn1RightPortCount(
parameters?: ComponentSymbolRendererProps["parameters"],
) {
const candidate = Number(parameters?.v1);
if (!Number.isInteger(candidate)) {
return LMECHN1_DEFAULT_RIGHT_PORT_COUNT;
}
return Math.min(
LMECHN1_MAX_RIGHT_PORT_COUNT,
Math.max(1, candidate),
);
}
export function lmechn1SymbolMetrics(
parameters?: ComponentSymbolRendererProps["parameters"],
) {
const rightPortCount = lmechn1RightPortCount(parameters);
const requestedBodyHeight = Math.max(
28,
12 + (rightPortCount - 1) * LMECHN1_PORT_ROW_SPACING,
);
const viewBoxHeight = Math.max(48, requestedBodyHeight + 8);
const bodyHeight = Math.min(requestedBodyHeight, viewBoxHeight - 8);
const bodyTop = (viewBoxHeight - bodyHeight) / 2;
const rightPortSpan = (rightPortCount - 1) * LMECHN1_PORT_ROW_SPACING;
const firstPortY = (viewBoxHeight - rightPortSpan) / 2;
const rightPortRows = Array.from(
{ length: rightPortCount },
(_, index) => firstPortY + index * LMECHN1_PORT_ROW_SPACING,
);
return {
bodyHeight,
bodyTop,
rightPortCount,
rightPortRows,
viewBoxHeight,
} as const;
}
export function AmesimLmechn1Symbol({
parameters,
surface,
}: ComponentSymbolRendererProps) {
// 组件库始终展示一个固定、易辨识的标准图;工作区才按实际端口数展开。
const metrics = lmechn1SymbolMetrics(
surface === "palette"
? { ...parameters, v1: LMECHN1_PALETTE_RIGHT_PORT_COUNT }
: parameters,
);
const centerY = metrics.viewBoxHeight / 2;
return (
<SymbolSvg className="component-symbol-amesim-lmechn1" domain="mechanical">
<rect
data-lmechn1-part="body"
fill={SYMBOL_FILL}
height="44"
height={metrics.bodyHeight}
rx="1"
stroke={SYMBOL_STROKE}
strokeWidth="1.8"
width="12"
x="27"
y="2"
strokeWidth="2.5"
width={LMECHN1_BODY_RIGHT - LMECHN1_BODY_LEFT}
x={LMECHN1_BODY_LEFT}
y={metrics.bodyTop}
/>
{leftPortRows.map((y) => (
<path
d={`M5 ${y}h22`}
key={y}
stroke={SYMBOL_STROKE}
strokeLinecap="round"
strokeWidth="1.4"
/>
{metrics.rightPortRows.map((y, index) => (
<g data-lmechn1-port={`port_${index + 1}`} key={index}>
<path
d={`M${LMECHN1_RIGHT_SOCKET_OUTER_X} ${y}H62`}
data-lmechn1-part="right-port-line"
fill="none"
stroke={SYMBOL_STROKE}
strokeLinecap="butt"
strokeWidth="1.5"
/>
<path
d={`M${LMECHN1_BODY_RIGHT} ${y - LMECHN1_SOCKET_HALF_HEIGHT}H${LMECHN1_RIGHT_SOCKET_OUTER_X}V${y + LMECHN1_SOCKET_HALF_HEIGHT}H${LMECHN1_BODY_RIGHT}`}
data-lmechn1-part="right-port-socket"
fill="none"
stroke={SYMBOL_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth="1.5"
/>
</g>
))}
<path d="M39 24h20" stroke={SYMBOL_STROKE} strokeWidth="1.8" />
<circle cx="33" cy="24" fill={SYMBOL_ACCENT} r="2.2" />
<g data-lmechn1-part="reference-port">
<path
d={`M2 ${centerY}H${LMECHN1_LEFT_SOCKET_OUTER_X}`}
data-lmechn1-part="reference-port-line"
fill="none"
stroke={SYMBOL_STROKE}
strokeLinecap="butt"
strokeWidth="1.5"
/>
<path
d={`M${LMECHN1_BODY_LEFT} ${centerY - LMECHN1_SOCKET_HALF_HEIGHT}H${LMECHN1_LEFT_SOCKET_OUTER_X}V${centerY + LMECHN1_SOCKET_HALF_HEIGHT}H${LMECHN1_BODY_LEFT}`}
data-lmechn1-part="reference-port-socket"
fill="none"
stroke={SYMBOL_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth="1.5"
/>
</g>
<text
data-lmechn1-part="label"
fill={SYMBOL_STROKE}
fontFamily="Arial, sans-serif"
fontSize="6.2"
fontWeight="600"
stroke="none"
textAnchor="middle"
x="32"
y={centerY - 6.2}
>
<tspan x="32">linear</tspan>
<tspan dy="6.5" x="32">node</tspan>
<tspan dy="6.5" x="32">(displ.)</tspan>
</text>
</SymbolSvg>
);
}
function Pnrp17ArrowPair({ centerY }: { centerY: number }) {
return (
<g
data-pnrp17-part="chamber-arrows"
fill="none"
stroke={PNRP17_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth={PNRP17_DETAIL_STROKE_WIDTH}
transform={`translate(0 ${centerY})`}
>
<g data-pnrp17-arrow="left" transform="translate(18.2 0)">
<path d="M9.1 0H3.9" />
<path d="M3.9 -2.5 0 0 3.9 2.5Z" fill="#ffffff" />
</g>
<g
data-pnrp17-arrow="right"
transform="translate(38.8 0) scale(-1 1)"
>
<path d="M9.1 0H3.9" />
<path d="M3.9 -2.5 0 0 3.9 2.5Z" fill="#ffffff" />
</g>
</g>
);
}
export function AmesimPnrp17Symbol() {
return (
<SymbolSvg className="component-symbol-amesim-pnrp17" domain="mechanical">
<path
d="M13.4 1.1H51.4V3h4.1v2.9h-4.1v1.9H18.2v13.8h-10V7h5.2V5.9H9.3V3h4.1Z"
fill={PNRP17_FILL}
stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<path
d="M8.2 26.7h12.7v13.8h2v6.7H8.2Z"
fill={PNRP17_FILL}
stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<path
d="M28 40.5h28.5v6.7H28Z"
fill={PNRP17_FILL}
stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<rect
fill={PNRP17_LIGHT_FILL}
height="34"
stroke={PNRP17_STROKE}
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
width="7.7"
x="40.8"
y="7.2"
/>
<rect fill={PNRP17_STROKE} height="34" width="1.8" x="38.8" y="7.2" />
<rect
fill={PNRP17_LIGHT_FILL}
height="2.8"
stroke={PNRP17_STROKE}
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
width="31.5"
x="9.3"
y="22.7"
/>
<rect
fill={PNRP17_LIGHT_FILL}
height="2.8"
stroke={PNRP17_STROKE}
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
width="7.7"
x="47.8"
y="22.7"
/>
<path
d="M2 4.4h7.3m46.2 0H62M2 24.1h7.3m46.2 0H62M25.5 43.4V48"
stroke={PNRP17_STROKE}
strokeLinecap="butt"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
{[15.3, 33].map((centerY) => (
<g
<g data-pnrp17-part="symbol" transform={PNRP17_CONTENT_TRANSFORM}>
<path
data-pnrp17-part="upper-shell"
d="M13.4 1.1H51.4V3h4.1v2.9h-4.1v1.9H18.2V21.1H3.3V7.3H13.4V5.9H9.3V3h4.1Z"
fill={PNRP17_FILL}
stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<rect
data-pnrp17-part="upper-left-wall"
fill={PNRP17_STROKE}
height="13.8"
width="2.9"
x="15.3"
y="7.3"
/>
<path
data-pnrp17-part="lower-left-shell"
d="M3.3 27H18.2V40.7H20V46.6H3.3Z"
fill={PNRP17_FILL}
stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<rect
data-pnrp17-part="lower-left-wall"
fill={PNRP17_STROKE}
height="13.7"
width="2.9"
x="15.3"
y="27"
/>
<path
data-pnrp17-part="lower-base"
d="M28 40.5h28.5v6.7H28Z"
fill={PNRP17_FILL}
stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<rect
data-pnrp17-part="piston-sleeve"
fill={PNRP17_LIGHT_FILL}
height="34"
stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
width="7.7"
x="40.8"
y="7.2"
/>
<rect
data-pnrp17-part="piston-face"
fill={PNRP17_STROKE}
height="34"
width="1.8"
x="38.8"
y="7.2"
/>
<rect
data-pnrp17-part="horizontal-rod-left"
fill={PNRP17_LIGHT_FILL}
height="2.8"
stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
width="31.5"
x="9.3"
y="22.7"
/>
<rect
data-pnrp17-part="horizontal-rod-right"
fill={PNRP17_LIGHT_FILL}
height="2.8"
stroke={PNRP17_STROKE}
strokeLinejoin="miter"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
width="7.7"
x="47.8"
y="22.7"
/>
<path
data-pnrp17-part="horizontal-extensions"
d="M2 4.4h7.3m46.2 0H62M2 24.1h7.3m46.2 0H62"
fill="none"
key={centerY}
stroke={PNRP17_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth={PNRP17_DETAIL_STROKE_WIDTH}
>
<path d={`M28.8 ${centerY}h-3.4`} />
<path
d={`M25.4 ${centerY - 2.5} 21.5 ${centerY}l3.9 2.5Z`}
fill="#ffffff"
/>
<path d={`M30.9 ${centerY}h3.4`} />
<path
d={`M34.3 ${centerY - 2.5} 38.3 ${centerY} 34.3 ${centerY + 2.5}Z`}
fill="#ffffff"
/>
</g>
))}
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<path
data-pnrp17-part="bottom-port"
d="M25.5 43.4V48"
fill="none"
stroke={PNRP17_STROKE}
strokeLinecap="butt"
strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH}
/>
<Pnrp17ArrowPair centerY={15.15} />
<Pnrp17ArrowPair centerY={32.9} />
</g>
</SymbolSvg>
);
}
+82 -33
View File
@@ -118,6 +118,26 @@ export function AmesimPnvo001SignalSymbol() {
type PipeSection = "C" | "R";
const PIPE_NETWORK_CONTENT_CENTER = { x: 32, y: 24 } as const;
const PIPE_NETWORK_CONTENT_SCALE = 0.64;
const PIPE_NETWORK_CONTENT_TRANSFORM = `translate(${PIPE_NETWORK_CONTENT_CENTER.x} ${PIPE_NETWORK_CONTENT_CENTER.y}) scale(${PIPE_NETWORK_CONTENT_SCALE}) translate(${-PIPE_NETWORK_CONTENT_CENTER.x} ${-PIPE_NETWORK_CONTENT_CENTER.y})`;
function scaledPipeNetworkPoint(x: number, y: number) {
return {
x:
PIPE_NETWORK_CONTENT_CENTER.x +
(x - PIPE_NETWORK_CONTENT_CENTER.x) * PIPE_NETWORK_CONTENT_SCALE,
y:
PIPE_NETWORK_CONTENT_CENTER.y +
(y - PIPE_NETWORK_CONTENT_CENTER.y) * PIPE_NETWORK_CONTENT_SCALE,
};
}
export const PIPE_NETWORK_PORT_ANCHORS = {
left: scaledPipeNetworkPoint(2, 24),
right: scaledPipeNetworkPoint(62, 24),
} as const;
function PipeNetworkBody({ sections }: { sections: readonly PipeSection[] }) {
const left = 9;
const right = 55;
@@ -126,7 +146,10 @@ function PipeNetworkBody({ sections }: { sections: readonly PipeSection[] }) {
const sectionWidth = (right - left) / sections.length;
return (
<>
<g
data-pipe-network-content="true"
transform={PIPE_NETWORK_CONTENT_TRANSFORM}
>
<path
d="M2 24h7M55 24h7"
stroke={SYMBOL_STROKE}
@@ -169,7 +192,7 @@ function PipeNetworkBody({ sections }: { sections: readonly PipeSection[] }) {
{section}
</text>
))}
</>
</g>
);
}
@@ -209,34 +232,50 @@ export function AmesimPnl0003Symbol() {
);
}
function JunctionTerminal({ x, y }: { x: number; y: number }) {
return (
<circle
cx={x}
cy={y}
fill={SYMBOL_FILL}
r="2"
stroke={SYMBOL_STROKE}
strokeWidth="1.3"
/>
);
}
const JUNCTION_STROKE = "#8b134f";
const JUNCTION_FINE_STROKE_WIDTH = 1.5;
const JUNCTION_COLLECTOR_STROKE_WIDTH = 4;
const JUNCTION_CENTER_RADIUS = 2.75;
export const PN3NODE2_PORT_ANCHORS = {
port1: { x: 36.6, y: 11.2 },
port2: { x: 24.6, y: 24 },
port3: { x: 36.6, y: 36.8 },
} as const;
export const P4NODE2_PORT_ANCHORS = {
port1: { x: 32, y: 11.2 },
port2: { x: 19.2, y: 24 },
port3: { x: 44.8, y: 24 },
port4: { x: 32, y: 36.8 },
} as const;
/** Three-port pneumatic junction with one inlet-side and two outlet-side ports. */
export function AmesimPn3Node2Symbol() {
return (
<SymbolSvg className="component-symbol-amesim-pn3node2-svg" domain="pneumatic">
<path
d="M4 24h25M29 24C41 24 42 12 60 12M29 24c12 0 13 12 31 12"
stroke={SYMBOL_STROKE}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={PNEUMATIC_STROKE_WIDTH}
d="M36.6 11.2V36.8M24.6 24H27.5"
data-junction-part="fine-branches"
stroke={JUNCTION_STROKE}
strokeLinecap="butt"
strokeWidth={JUNCTION_FINE_STROKE_WIDTH}
/>
<path
d="M27.5 24H36.6"
data-junction-part="collector-branch"
data-port-name="port_2"
stroke={JUNCTION_STROKE}
strokeLinecap="butt"
strokeWidth={JUNCTION_COLLECTOR_STROKE_WIDTH}
/>
<circle
cx="36.6"
cy="24"
data-junction-part="center"
fill={JUNCTION_STROKE}
r={JUNCTION_CENTER_RADIUS}
/>
<circle cx="29" cy="24" fill={SYMBOL_ACCENT} r="3" />
<JunctionTerminal x={3} y={24} />
<JunctionTerminal x={61} y={12} />
<JunctionTerminal x={61} y={36} />
</SymbolSvg>
);
}
@@ -246,17 +285,27 @@ export function AmesimP4Node2Symbol() {
return (
<SymbolSvg className="component-symbol-amesim-p4node2-svg" domain="pneumatic">
<path
d="M4 24h24M28 24C40 24 42 8 60 8M28 24h32M28 24c12 0 14 16 32 16"
stroke={SYMBOL_STROKE}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={PNEUMATIC_STROKE_WIDTH}
d="M32 11.2V36.8M32 24H44.8M19.2 24H22.1"
data-junction-part="fine-branches"
stroke={JUNCTION_STROKE}
strokeLinecap="butt"
strokeWidth={JUNCTION_FINE_STROKE_WIDTH}
/>
<path
d="M22.1 24H32"
data-junction-part="collector-branch"
data-port-name="port_2"
stroke={JUNCTION_STROKE}
strokeLinecap="butt"
strokeWidth={JUNCTION_COLLECTOR_STROKE_WIDTH}
/>
<circle
cx="32"
cy="24"
data-junction-part="center"
fill={JUNCTION_STROKE}
r={JUNCTION_CENTER_RADIUS}
/>
<circle cx="28" cy="24" fill={SYMBOL_ACCENT} r="3" />
<JunctionTerminal x={3} y={24} />
<JunctionTerminal x={61} y={8} />
<JunctionTerminal x={61} y={24} />
<JunctionTerminal x={61} y={40} />
</SymbolSvg>
);
}
+4 -2
View File
@@ -42,8 +42,6 @@ export type ComponentSymbolLayout = {
symbolSize: ComponentSymbolSize;
nodeSize: ComponentSymbolSize;
portAnchors?: ComponentSymbolPortAnchors;
/** 仅用于识别 presentationLayoutVersion 之前保存的固定尺寸接触连接。 */
legacyPortAnchors?: ComponentSymbolPortAnchors;
};
export type ComponentSymbolParameters = Readonly<
@@ -52,11 +50,15 @@ export type ComponentSymbolParameters = Readonly<
export type ComponentSymbolRendererProps = {
parameters?: ComponentSymbolParameters;
surface?: "palette" | "canvas";
};
export type SymbolDefinition = {
presentation: ComponentSymbolPresentation;
render: ComponentType<ComponentSymbolRendererProps>;
layout: ComponentSymbolLayout;
layoutForParameters?: (
parameters?: ComponentSymbolParameters,
) => ComponentSymbolLayout;
variantKey?: (parameters?: ComponentSymbolParameters) => string;
};
+536 -171
View File
@@ -460,7 +460,7 @@ textarea {
.palette-list {
display: block;
padding: 12px;
padding: 10px;
}
.parameter-form {
@@ -965,46 +965,14 @@ textarea {
flex-wrap: wrap;
}
.palette-display-mode {
display: inline-flex;
flex: 0 0 auto;
gap: 1px;
padding: 1px;
border: 1px solid #cad5e1;
border-radius: 5px;
background: #f4f7fa;
}
.palette-display-mode-button {
display: grid;
width: 23px;
height: 22px;
padding: 0;
border: 0;
border-radius: 3px;
place-items: center;
color: #64748b;
background: transparent;
cursor: pointer;
}
.palette-display-mode-button:hover {
color: #155a94;
background: #e6f0f8;
}
.palette-display-mode-button:focus-visible {
outline: 2px solid #1d6fb8;
outline-offset: 1px;
}
.palette-display-mode-button[aria-pressed="true"] {
color: #155a94;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.16);
.palette-panel-title > span {
font-size: 16px;
font-weight: 700;
line-height: 1.25;
}
.catalog-source {
position: relative;
display: inline-flex;
align-items: center;
gap: 5px;
@@ -1014,19 +982,54 @@ textarea {
white-space: nowrap;
}
.catalog-source > span {
.catalog-source-dot {
flex: 0 0 auto;
width: 7px;
height: 7px;
border-radius: 50%;
background: #d49b16;
}
.catalog-source.ready > span {
.catalog-source.ready > .catalog-source-dot {
background: #198754;
}
.catalog-source.fallback > span {
background: #b45309;
.catalog-source.error {
color: #9f1d1d;
cursor: help;
}
.catalog-source.error > .catalog-source-dot {
background: #c53030;
}
.catalog-source-tooltip {
position: absolute;
top: calc(100% + 7px);
right: 0;
z-index: 30;
width: 250px;
box-sizing: border-box;
padding: 8px 10px;
border: 1px solid #e3a4a4;
border-radius: 5px;
color: #7f1d1d;
font-size: 11px;
font-weight: 500;
line-height: 1.45;
white-space: normal;
background: #fff7f7;
box-shadow: 0 6px 16px rgba(75, 20, 20, 0.18);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 120ms ease, visibility 120ms ease;
}
.catalog-source.error:hover .catalog-source-tooltip,
.catalog-source.error:focus-visible .catalog-source-tooltip {
opacity: 1;
visibility: visible;
}
.palette-library + .palette-library {
@@ -1039,18 +1042,24 @@ textarea {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
gap: 4px;
margin-bottom: 12px;
}
.palette-library-heading strong {
min-width: 0;
overflow: hidden;
color: #17212b;
font-size: 12px;
font-size: 14px;
font-weight: 700;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-library-heading small {
color: #64748b;
font-size: 10px;
font-size: 8px;
white-space: nowrap;
}
@@ -1061,8 +1070,12 @@ textarea {
.palette-category-title {
margin-bottom: 6px;
color: #526172;
font-size: 10px;
font-weight: 700;
}
.palette-category-title > span {
font-size: 12px;
font-weight: 600;
line-height: 1.35;
}
.palette-collapse-toggle {
@@ -1107,10 +1120,6 @@ textarea {
.palette-category-items {
display: grid;
gap: 8px;
}
.palette[data-view-mode="icons"] .palette-category-items {
grid-template-columns: repeat(auto-fill, 76px);
gap: 6px;
justify-content: start;
@@ -1126,15 +1135,19 @@ textarea {
.palette-item {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 52px;
padding: 8px;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 48px 16px;
gap: 4px;
width: 76px;
height: 82px;
min-height: 82px;
box-sizing: border-box;
padding: 6px 5px;
border: 1px solid #d6dee9;
border-radius: 6px;
place-items: center;
color: #22303f;
text-align: left;
text-align: center;
background: #ffffff;
cursor: grab;
transition: border-color 120ms ease, box-shadow 120ms ease,
@@ -1159,34 +1172,36 @@ textarea {
}
.palette-item-copy {
display: block;
width: 100%;
min-width: 0;
overflow: hidden;
line-height: 16px;
text-align: center;
}
.palette-item-copy strong {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-item small {
display: block;
margin-top: 2px;
overflow: hidden;
color: #64748b;
font-size: 11px;
font-size: 10px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-icon {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 48px;
width: 48px;
height: 48px;
place-items: center;
}
.palette-canvas-drag-templates {
display: none;
}
.component-symbol {
display: grid;
width: 100%;
@@ -1252,51 +1267,6 @@ textarea {
font-weight: 600;
}
.palette[data-view-mode="icons"] .palette-list {
padding: 10px;
}
.palette[data-view-mode="icons"] .palette-item {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 48px 16px;
gap: 4px;
width: 76px;
height: 82px;
min-height: 82px;
box-sizing: border-box;
padding: 6px 5px;
place-items: center;
text-align: center;
}
.palette[data-view-mode="icons"] .palette-item-copy {
display: block;
width: 100%;
min-width: 0;
overflow: hidden;
line-height: 16px;
text-align: center;
}
.palette[data-view-mode="icons"] .palette-item-copy strong {
width: 100%;
overflow: hidden;
font-size: 10px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette[data-view-mode="icons"] .palette-item-copy small {
display: none;
}
.palette[data-view-mode="icons"] .palette-icon {
flex: 0 0 48px;
width: 48px;
height: 48px;
}
.palette-icon-preview {
position: fixed;
z-index: 10000;
@@ -1360,6 +1330,99 @@ textarea {
background: #f8fafc;
}
.modeling-canvas-stack {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.flow-viewport-scrollbars {
--flow-viewport-scrollbar-size: 12px;
position: absolute;
z-index: 7;
inset: 0;
pointer-events: none;
}
.flow-viewport-scrollbar {
position: absolute;
margin: 0;
overflow: scroll;
background: #e5eaf0;
outline: none;
overscroll-behavior: contain;
pointer-events: auto;
scrollbar-color: #8493a5 #e5eaf0;
scrollbar-width: thin;
}
.flow-viewport-scrollbar-horizontal {
right: var(--flow-viewport-scrollbar-size);
bottom: 0;
left: 0;
height: var(--flow-viewport-scrollbar-size);
overflow-x: scroll;
overflow-y: hidden;
}
.flow-viewport-scrollbar-vertical {
top: 0;
right: 0;
bottom: var(--flow-viewport-scrollbar-size);
width: var(--flow-viewport-scrollbar-size);
overflow-x: hidden;
overflow-y: scroll;
}
.flow-viewport-scrollbar-spacer {
display: block;
min-width: 1px;
min-height: 1px;
pointer-events: none;
}
.flow-viewport-scrollbar-corner {
position: absolute;
right: 0;
bottom: 0;
width: var(--flow-viewport-scrollbar-size);
height: var(--flow-viewport-scrollbar-size);
background: #e5eaf0;
box-shadow: inset 1px 1px #d5dce5;
pointer-events: auto;
}
.flow-viewport-scrollbar:focus-visible {
box-shadow: inset 0 0 0 2px #2f72ad;
}
.flow-viewport-scrollbar::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.flow-viewport-scrollbar::-webkit-scrollbar-track {
background: #e5eaf0;
}
.flow-viewport-scrollbar::-webkit-scrollbar-thumb {
min-width: 28px;
min-height: 28px;
border: 2px solid #e5eaf0;
border-radius: 999px;
background: #8493a5;
}
.flow-viewport-scrollbar::-webkit-scrollbar-thumb:hover {
background: #65768a;
}
.flow-canvas .react-flow__panel.bottom {
bottom: 14px;
}
.recovery-banner button {
min-width: 48px;
height: 28px;
@@ -1461,6 +1524,41 @@ textarea {
pointer-events: auto;
}
.flow-canvas .react-flow__node.pending-paste-node,
.flow-canvas .react-flow__node.pending-paste-node .sim-node,
.flow-canvas .react-flow__node.pending-paste-node .node-symbol-envelope,
.flow-canvas .react-flow__node.pending-paste-node .port-handle {
cursor: none;
pointer-events: none !important;
}
.flow-canvas .react-flow__node.pending-paste-node {
opacity: 0.72;
filter: drop-shadow(0 0 4px rgba(29, 111, 184, 0.5));
}
.flow-canvas .react-flow__edge.pending-paste-edge {
opacity: 0.72;
pointer-events: none;
}
.flow-canvas .react-flow__edge.pending-paste-edge .react-flow__edge-path {
stroke: #1d6fb8;
stroke-dasharray: 5 4;
}
.pending-paste-hint {
padding: 7px 11px;
border: 1px solid #76a8cf;
border-radius: 5px;
color: #174f7a;
background: rgba(239, 248, 255, 0.96);
box-shadow: 0 2px 8px rgba(23, 79, 122, 0.15);
font-size: 12px;
font-weight: 650;
pointer-events: none;
}
.react-flow__node.validation-error .sim-node-frame {
border-color: #c63b32;
box-shadow: 0 0 0 3px rgba(198, 59, 50, 0.14);
@@ -1575,26 +1673,57 @@ textarea {
}
.port-handle {
--port-handle-visual-size: 6px;
z-index: 3;
width: 9px;
height: 9px;
width: var(--port-handle-visual-size);
min-width: var(--port-handle-visual-size);
max-width: var(--port-handle-visual-size);
height: var(--port-handle-visual-size);
min-height: var(--port-handle-visual-size);
max-height: var(--port-handle-visual-size);
aspect-ratio: 1 / 1;
box-sizing: border-box;
border: 1.5px solid var(--port-handle-color, #475569);
border: 0;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.82);
background: transparent;
box-shadow: none;
opacity: 1;
transition: opacity 100ms ease, border-color 100ms ease,
background 100ms ease, box-shadow 100ms ease;
transition: opacity 100ms ease;
}
.port-handle::before {
position: absolute;
inset: -4.5px;
border-radius: inherit;
background: transparent;
content: "";
pointer-events: inherit;
}
.port-handle-physical {
--port-handle-color: #a12b70;
border-radius: 50%;
}
.port-handle-signal {
--port-handle-color: #dc2626;
border-radius: 2px;
border-radius: 1.5px;
}
.port-handle-glyph {
display: block;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
transition: filter 100ms ease;
}
.port-handle-glyph-shape {
fill: var(--port-handle-fill, #ffffff);
stroke: var(--port-handle-color, #475569);
stroke-width: 1;
transition: fill 100ms ease, stroke 100ms ease;
}
.port-handle.port-connection-connected {
@@ -1602,18 +1731,46 @@ textarea {
opacity: 0;
}
.model-contract-warning {
position: absolute;
z-index: 6;
top: -9px;
right: -9px;
display: grid;
width: 18px;
height: 18px;
border: 2px solid #ffffff;
border-radius: 50%;
color: #ffffff;
background: #c53b32;
box-shadow: 0 1px 4px rgba(127, 29, 29, 0.42);
font-size: 12px;
font-weight: 800;
line-height: 1;
pointer-events: auto;
place-items: center;
}
.sim-node.model-contract-incompatible .component-symbol-svg {
filter: drop-shadow(0 0 3px rgba(198, 59, 50, 0.72));
}
.port-handle.port-connection-origin {
border-color: #d97706;
background: #fff7d6;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.24),
0 0 8px rgba(217, 119, 6, 0.42);
--port-handle-color: #d97706;
--port-handle-fill: #fff7d6;
}
.port-handle.port-connection-origin .port-handle-glyph {
filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.46));
}
.port-handle.port-connection-compatible {
border-color: #15803d;
background: #dcfce7;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.24),
0 0 9px rgba(21, 128, 61, 0.48);
--port-handle-color: #15803d;
--port-handle-fill: #dcfce7;
}
.port-handle.port-connection-compatible .port-handle-glyph {
filter: drop-shadow(0 0 4px rgba(21, 128, 61, 0.5));
}
.port-handle.port-connection-incompatible {
@@ -1669,38 +1826,6 @@ textarea {
filter: drop-shadow(0 0 2px rgba(29, 111, 184, 0.42));
}
.contact-edge-hit {
cursor: pointer;
fill: transparent;
pointer-events: all;
stroke: transparent;
}
.contact-edge-marker {
fill: #ffffff;
opacity: 0;
pointer-events: none;
stroke: #64748b;
stroke-width: 1.5;
transition: opacity 120ms ease-out, fill 120ms ease-out, stroke 120ms ease-out;
vector-effect: non-scaling-stroke;
}
.react-flow__edge.editor-edge-contact:hover .contact-edge-marker,
.react-flow__edge.editor-edge-contact.selected .contact-edge-marker,
.react-flow__edge.editor-edge-contact.editor-edge-selected .contact-edge-marker,
.contact-edge-hit:focus-visible + .contact-edge-marker {
opacity: 1;
}
.react-flow__edge.editor-edge-contact.selected .contact-edge-marker,
.react-flow__edge.editor-edge-contact.editor-edge-selected .contact-edge-marker,
.contact-edge-hit:focus-visible + .contact-edge-marker {
fill: #dbeafe;
stroke: #1d6fb8;
stroke-width: 2;
}
.field {
display: grid;
gap: 6px;
@@ -2150,15 +2275,6 @@ textarea {
stroke-width: 1.5;
}
.results-system-canvas .contact-edge-hit {
cursor: default;
pointer-events: none;
}
.results-system-canvas .contact-edge-marker {
opacity: 0;
}
.results-chart-workspace {
position: relative;
z-index: 0;
@@ -2844,6 +2960,255 @@ textarea {
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22);
}
/* 功能 4 的审阅版使用独立 dock 前缀;旧浮动控制台样式保留,便于快速回退。 */
.simulation-console-dock {
position: relative;
z-index: 30;
display: grid;
grid-template-rows: 38px 50px minmax(0, 1fr);
width: 100%;
height: var(--simulation-console-dock-height, 180px);
min-height: 0;
overflow: visible;
border-top: 1px solid #718297;
color: #263442;
background: #ffffff;
box-shadow: 0 -3px 12px rgba(15, 23, 42, 0.14);
}
.simulation-console-dock.minimized {
display: block;
overflow: hidden;
}
.simulation-console-dock.resizing {
cursor: row-resize;
user-select: none;
}
.simulation-console-dock-resizer {
position: absolute;
z-index: 2;
top: -4px;
right: 0;
left: 0;
height: 8px;
cursor: row-resize;
touch-action: none;
}
.simulation-console-dock-resizer::after {
position: absolute;
top: 3px;
right: 0;
left: 0;
height: 2px;
background: transparent;
content: "";
transition: background 120ms ease;
}
.simulation-console-dock-resizer:hover::after,
.simulation-console-dock-resizer:focus-visible::after,
.simulation-console-dock.resizing .simulation-console-dock-resizer::after {
background: #2f72ad;
}
.simulation-console-dock-resizer:focus-visible {
outline: 2px solid #2f72ad;
outline-offset: -2px;
}
.simulation-console-dock-header {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
padding: 0 10px 0 12px;
border-bottom: 1px solid #d4dde7;
background: #f4f7f9;
}
.simulation-console-dock.minimized .simulation-console-dock-header {
height: 40px;
border-bottom: 0;
}
.simulation-console-dock-title,
.simulation-console-dock-actions {
display: flex;
align-items: center;
}
.simulation-console-dock-title {
min-width: 0;
flex: 1 1 auto;
gap: 8px;
}
.simulation-console-dock-title strong {
flex: 0 0 auto;
font-size: 12px;
}
.simulation-console-dock-summary {
min-width: 0;
overflow: hidden;
color: #657487;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.simulation-console-dock-actions {
flex: 0 0 auto;
gap: 2px;
margin-left: 12px;
}
.simulation-console-dock-actions button {
display: grid;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
color: #526172;
background: transparent;
cursor: pointer;
place-items: center;
}
.simulation-console-dock-actions button:hover:not(:disabled) {
border-color: #bdc9d5;
color: #155a94;
background: #ffffff;
}
.simulation-console-dock-actions button:disabled {
cursor: default;
opacity: 0.38;
}
.simulation-console-dock-actions button.active {
border-color: #e2aaa5;
color: #b42318;
background: #fff1f0;
}
.simulation-console-dock-progress {
padding: 8px 12px 9px;
border-bottom: 1px solid #dbe2ea;
background: #ffffff;
}
.simulation-console-dock-progress-label {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
margin-bottom: 6px;
color: #526172;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.simulation-console-dock-progress-label > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.simulation-console-dock-progress-label > span:last-of-type,
.simulation-console-dock-progress-label strong {
color: #1767a8;
font-weight: 650;
}
.simulation-console-dock-progress-track {
height: 6px;
overflow: hidden;
border-radius: 3px;
background: #e2e8f0;
}
.simulation-console-dock-progress-track > span {
display: block;
height: 100%;
background: #2478b8;
transition: width 160ms ease-out;
}
.simulation-console-dock-progress.success .simulation-console-dock-progress-track > span {
background: #2f7d55;
}
.simulation-console-dock-progress.warning .simulation-console-dock-progress-track > span {
background: #b7791f;
}
.simulation-console-dock-progress.error .simulation-console-dock-progress-track > span {
background: #c13d35;
}
.simulation-console-dock-log {
min-height: 0;
padding: 8px 12px 14px;
overflow: auto;
background: #fbfcfd;
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-size: 11px;
line-height: 1.55;
user-select: text;
}
.simulation-console-dock-empty {
padding: 12px 4px;
color: #8a97a6;
}
.simulation-console-dock-entry {
display: grid;
grid-template-columns: 66px 44px minmax(0, 1fr);
align-items: start;
gap: 8px;
padding: 3px 4px;
border-bottom: 1px solid #edf1f5;
}
.simulation-console-dock-entry time,
.simulation-console-dock-entry-level {
color: #7a8796;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.simulation-console-dock-entry.success .simulation-console-dock-entry-level {
color: #2f7d55;
}
.simulation-console-dock-entry.warning .simulation-console-dock-entry-level {
color: #9a6417;
}
.simulation-console-dock-entry.error .simulation-console-dock-entry-level {
color: #b42318;
}
.simulation-console-dock-entry-message {
min-width: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.simulation-console-dock-entry-xml {
min-width: 0;
margin: 0;
overflow: auto;
color: #334155;
white-space: pre;
}
.simulation-console.dragging {
user-select: none;
}
+12 -4
View File
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
import { prepareApp } from "./fixtures";
import { expandSimulationConsole, prepareApp } from "./fixtures";
test.beforeEach(async ({ page }) => {
await prepareApp(page);
@@ -62,8 +62,9 @@ test("拖入介质定义后可供气动元件选择,删除定义会回退到
);
await page.getByRole("button", { name: "生成系统 XML" }).click();
await expandSimulationConsole(page);
const xmlBlock = consolePanel.getByLabel("生成的系统 XML");
await expect(xmlBlock).toContainText('mediumReferenceVersion="1"');
await expect(xmlBlock).toContainText('schemaVersion="3"');
await expect(xmlBlock).toContainText('<Parameter name="gi" value="0"');
});
@@ -82,6 +83,7 @@ test("介质定义的物性计算模型会写入 XML", async ({ page }) => {
await expect(page.getByLabel("物性计算模型")).toHaveValue("0");
await page.getByRole("button", { name: "生成系统 XML" }).click();
await expandSimulationConsole(page);
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
@@ -133,6 +135,7 @@ test("氦气介质自动分配 gi,并以 Peng–Robinson 模型写入 XML", as
await expect(mediumSelect).toHaveValue("2");
await page.getByRole("button", { name: "生成系统 XML" }).click();
await expandSimulationConsole(page);
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
@@ -156,8 +159,8 @@ test("氦气介质自动分配 gi,并以 Peng–Robinson 模型写入 XML", as
test("导入工程中的未知物性计算模型会被检查出来", async ({ page }) => {
await page.goto("/");
const project = {
projectSchemaVersion: 1,
name: "invalid-property-model",
mediumReferenceVersion: 1,
nodes: [
{
id: "amesim_ideal_air_medium_1",
@@ -196,7 +199,12 @@ test("导入工程中的未知物性计算模型会被检查出来", async ({ pa
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).toContainText(
await expect(consolePanel).toHaveClass(/minimized/);
await expect(
consolePanel.locator(".simulation-console-dock-summary"),
).toContainText("模型检查发现");
await expandSimulationConsole(page);
await expect(consolePanel.locator(".simulation-console-dock-log")).toContainText(
"空气介质_1:物性计算模型不在支持的可选项中",
);
});
File diff suppressed because it is too large. Load diff
+214 -6
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import {
expectAllNodesInsideCanvas,
prepareApp,
@@ -6,14 +6,39 @@ import {
wideProject,
} from "./fixtures";
async function readModelingViewport(page: Page) {
return page
.locator(".flow-canvas .react-flow__viewport")
.evaluate((viewport) => {
const values =
getComputedStyle(viewport)
.transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)
?.map(Number) ?? [];
return {
x: values[4] ?? 0,
y: values[5] ?? 0,
zoom: values[0] ?? 1,
};
});
}
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
await prepareApp(page);
await page.goto("/");
const viewport = page.locator(".flow-canvas .react-flow__viewport");
const zoomOut = page.locator(".flow-canvas .react-flow__controls-zoomout");
await zoomOut.click();
await zoomOut.click();
await expect(page.locator(".flow-canvas .react-flow__controls")).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__attribution")).toHaveCount(0);
const pane = page.locator(".flow-canvas .react-flow__pane");
const paneBox = await pane.boundingBox();
expect(paneBox).not.toBeNull();
await page.mouse.move(
paneBox!.x + paneBox!.width / 2,
paneBox!.y + paneBox!.height / 2,
);
await page.keyboard.down("Control");
await page.mouse.wheel(0, 240);
await page.keyboard.up("Control");
await page.waitForTimeout(250);
const viewportBeforeDrop = await viewport.getAttribute("style");
expect(viewportBeforeDrop).toBeTruthy();
@@ -31,6 +56,133 @@ test("拖入组件不自动适配画布,手动适配按钮仍然生效", async
.not.toBe(viewportBeforeDrop);
});
test("普通滚轮纵向平移,Ctrl 滚轮缩放且滚动条和中键拖动保持同步", async ({
page,
}) => {
await prepareApp(page);
await page.goto("/");
const canvas = page.locator(".flow-canvas");
const pane = canvas.locator(".react-flow__pane");
const horizontalScrollbar = page.getByRole("scrollbar", {
name: "水平滚动画布",
});
const verticalScrollbar = page.getByRole("scrollbar", {
name: "垂直滚动画布",
});
await expect(horizontalScrollbar).toBeVisible();
await expect(verticalScrollbar).toBeVisible();
const horizontalBox = await horizontalScrollbar.boundingBox();
const verticalBox = await verticalScrollbar.boundingBox();
expect(horizontalBox).not.toBeNull();
expect(verticalBox).not.toBeNull();
expect(horizontalBox!.height).toBeGreaterThanOrEqual(8);
expect(horizontalBox!.height).toBeLessThanOrEqual(12);
expect(verticalBox!.width).toBeGreaterThanOrEqual(8);
expect(verticalBox!.width).toBeLessThanOrEqual(12);
const paneBox = await pane.boundingBox();
expect(paneBox).not.toBeNull();
const pointer = {
x: paneBox!.x + paneBox!.width * 0.55,
y: paneBox!.y + paneBox!.height * 0.45,
};
await page.mouse.move(pointer.x, pointer.y);
const beforeWheel = await readModelingViewport(page);
const beforeVerticalScroll = await verticalScrollbar.evaluate(
(element) => element.scrollTop,
);
await page.mouse.wheel(0, 180);
await expect
.poll(async () => (await readModelingViewport(page)).y)
.not.toBeCloseTo(beforeWheel.y, 1);
const afterWheel = await readModelingViewport(page);
expect(afterWheel.x).toBeCloseTo(beforeWheel.x, 1);
expect(afterWheel.zoom).toBeCloseTo(beforeWheel.zoom, 3);
await expect
.poll(() => verticalScrollbar.evaluate((element) => element.scrollTop))
.not.toBeCloseTo(beforeVerticalScroll, 1);
const beforeZoom = await readModelingViewport(page);
const horizontalBeforeZoom = await horizontalScrollbar.evaluate(
(element) => element.scrollLeft,
);
const verticalBeforeZoom = await verticalScrollbar.evaluate(
(element) => element.scrollTop,
);
await page.keyboard.down("Control");
await page.mouse.wheel(0, -180);
await page.keyboard.up("Control");
await expect
.poll(async () => (await readModelingViewport(page)).zoom)
.not.toBeCloseTo(beforeZoom.zoom, 2);
const horizontalAfterZoom = await horizontalScrollbar.evaluate(
(element) => element.scrollLeft,
);
const verticalAfterZoom = await verticalScrollbar.evaluate(
(element) => element.scrollTop,
);
expect(
Math.hypot(
horizontalAfterZoom - horizontalBeforeZoom,
verticalAfterZoom - verticalBeforeZoom,
),
).toBeGreaterThan(1);
const beforeMiddleDrag = await readModelingViewport(page);
await page.mouse.move(pointer.x, pointer.y);
await page.mouse.down({ button: "middle" });
await page.mouse.move(pointer.x + 70, pointer.y + 45, { steps: 4 });
await page.mouse.up({ button: "middle" });
await expect
.poll(async () => {
const next = await readModelingViewport(page);
return Math.hypot(next.x - beforeMiddleDrag.x, next.y - beforeMiddleDrag.y);
})
.toBeGreaterThan(20);
const afterMiddleDrag = await readModelingViewport(page);
expect(afterMiddleDrag.zoom).toBeCloseTo(beforeMiddleDrag.zoom, 3);
await expect(canvas.locator(".react-flow__selection")).toHaveCount(0);
await verticalScrollbar.focus();
const beforeKeyboardScroll = await verticalScrollbar.evaluate(
(element) => element.scrollTop,
);
await page.keyboard.press("ArrowDown");
await expect
.poll(() => verticalScrollbar.evaluate((element) => element.scrollTop))
.toBeGreaterThan(beforeKeyboardScroll);
const beforeScrollbarDrag = await readModelingViewport(page);
await horizontalScrollbar.evaluate((element) => {
element.scrollLeft += 120;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect
.poll(async () => (await readModelingViewport(page)).x)
.not.toBeCloseTo(beforeScrollbarDrag.x, 1);
const afterScrollbarDrag = await readModelingViewport(page);
expect(afterScrollbarDrag.y).toBeCloseTo(beforeScrollbarDrag.y, 1);
expect(afterScrollbarDrag.zoom).toBeCloseTo(beforeScrollbarDrag.zoom, 3);
const horizontalMaximum = await horizontalScrollbar.evaluate(
(element) => element.scrollWidth - element.clientWidth,
);
const verticalMaximum = await verticalScrollbar.evaluate(
(element) => element.scrollHeight - element.clientHeight,
);
await expect(horizontalScrollbar).toHaveAttribute(
"aria-valuemax",
String(Math.round(horizontalMaximum)),
);
await expect(verticalScrollbar).toHaveAttribute(
"aria-valuemax",
String(Math.round(verticalMaximum)),
);
});
test("加载本地工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((project) => {
@@ -48,7 +200,10 @@ test("加载本地工程后自动适配建模画布", async ({ page }) => {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).not.toContainText("工程已加载");
await expect(consolePanel.locator(".simulation-console-dock-summary")).toContainText(
"工程已加载",
);
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toContainText("工程已加载");
});
@@ -83,7 +238,10 @@ test("恢复自动保存工程后自动适配建模画布", async ({ page }) =>
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).not.toContainText("已恢复上次自动保存的工程");
await expect(consolePanel.locator(".simulation-console-dock-summary")).toContainText(
"已恢复上次自动保存的工程",
);
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toContainText("已恢复上次自动保存的工程");
});
@@ -113,6 +271,9 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
);
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await expect(
page.locator(".results-system-canvas .react-flow__attribution"),
).toHaveCount(0);
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toHaveCount(0);
@@ -145,4 +306,51 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
);
await expect(idleResultHandles).toHaveCount(2);
await expect(connectedResultHandles.first()).toHaveCSS("opacity", "0");
await expect(
idleResultHandles.locator('[data-port-glyph-shape="circle"]'),
).toHaveCount(2);
const resultViewportZoom = await page
.locator(".results-system-canvas .react-flow__viewport")
.evaluate(
(element) =>
new DOMMatrixReadOnly(getComputedStyle(element).transform).a,
);
const idleResultPortShapes = await idleResultHandles.evaluateAll((elements) =>
elements.map((element) => {
const bounds = element.getBoundingClientRect();
const glyphBounds = element
.querySelector<SVGSVGElement>(".port-handle-glyph")
?.getBoundingClientRect();
const circle = element.querySelector<SVGCircleElement>(
'[data-port-glyph-shape="circle"]',
);
return {
circle: circle
? {
cx: circle.cx.baseVal.value,
cy: circle.cy.baseVal.value,
r: circle.r.baseVal.value,
}
: null,
glyphHeight: glyphBounds?.height ?? null,
glyphWidth: glyphBounds?.width ?? null,
height: bounds.height,
width: bounds.width,
};
}),
);
for (const shape of idleResultPortShapes) {
expect(Math.abs(shape.width - 6)).toBeLessThan(0.05);
expect(Math.abs(shape.height - 6)).toBeLessThan(0.05);
expect(shape.glyphWidth).not.toBeNull();
expect(shape.glyphHeight).not.toBeNull();
expect(
Math.abs(shape.glyphWidth! - 6 * resultViewportZoom),
).toBeLessThan(0.1);
expect(
Math.abs(shape.glyphHeight! - 6 * resultViewportZoom),
).toBeLessThan(0.1);
expect(Math.abs(shape.glyphWidth! - shape.glyphHeight!)).toBeLessThan(0.01);
expect(shape.circle).toEqual({ cx: 3, cy: 3, r: 2.5 });
}
});
+23 -1
View File
@@ -1,5 +1,15 @@
import { expect, type Page } from "@playwright/test";
export async function expandSimulationConsole(page: Page) {
const expandButton = page.getByRole("button", {
name: "展开仿真控制台",
exact: true,
});
if ((await expandButton.count()) > 0) {
await expandButton.click();
}
}
const physicalPort = (
name: string,
side: "left" | "right",
@@ -73,6 +83,15 @@ const ud00StageParameterNames = Array.from(
export const componentCatalog = {
schemaVersion: 1,
libraries: [
{
id: "experimental",
label: "临时测试组件库",
version: "0.1.0",
sourcePackage: "tests.e2e.experimental",
temporary: true,
order: 100,
components: [],
},
{
id: "e2e-library",
label: "自动化测试组件库",
@@ -323,7 +342,7 @@ export const componentCatalog = {
type: "amesim_mecmas21",
label: "MECMAS21 编码测试元件",
modelType: "amesim_mecmas21",
modelVersion: "1.0.0",
modelVersion: "0.2.0",
symbol: "symbol-not-registered",
order: 6,
category: {
@@ -868,6 +887,7 @@ const projectPort = (
});
export const wideProject = {
projectSchemaVersion: 1,
name: "demo-system",
nodes: Array.from({ length: 4 }, (_, index) => ({
id: `generic_sensor_${index + 1}`,
@@ -880,6 +900,7 @@ export const wideProject = {
label: `generic_sensor_${index + 1}`,
componentType: "generic_sensor",
modelType: "generic_sensor",
modelVersion: "1.0.0",
symbol: "symbol-not-registered",
ports: [
projectPort("port_a", "left", "bidirectional"),
@@ -897,6 +918,7 @@ export const wideProject = {
target: `generic_sensor_${index + 2}`,
sourceHandle: "port_b",
targetHandle: "port_a",
data: { isContactEdge: false },
})),
simulation: {
t_start: 0,
+397 -6
View File
@@ -1,11 +1,11 @@
import { expect, test } from "@playwright/test";
import { prepareApp } from "./fixtures";
import { prepareApp, wideProject } from "./fixtures";
test.beforeEach(async ({ page }) => {
await prepareApp(page);
});
test("未知图形元件使用带端口和名称的默认节点,并将动作结果写入控制台", async ({
test("未知图形元件使用带端口和名称的默认节点且拖入动作不污染仿真控制台", async ({
page,
}) => {
await page.goto("/");
@@ -31,26 +31,113 @@ test("未知图形元件使用带端口和名称的默认节点,并将动作
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).toHaveClass(/minimized/);
await expect(consolePanel).toHaveCSS("height", "40px");
await expect(consolePanel).not.toHaveClass(/has-unread/);
await expect(consolePanel.locator(".simulation-console-unread")).toHaveCount(0);
await expect(consolePanel).not.toContainText("已添加组件");
await expect(consolePanel.locator(".simulation-console-dock-summary")).toHaveText(
"等待仿真任务",
);
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
await page.getByRole("button", { name: "生成系统 XML" }).click();
await expect(consolePanel).toContainText("XML 已生成");
await expect(consolePanel).toHaveClass(/minimized/);
await expect(consolePanel).toHaveCSS("height", "40px");
await expect(consolePanel.locator(".simulation-console-dock-summary")).toHaveText(
"系统 XML 已生成",
);
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
await page.getByRole("button", { name: "展开仿真控制台" }).click();
const xmlBlock = consolePanel.getByLabel("生成的系统 XML");
await expect(xmlBlock).toBeVisible();
await expect(xmlBlock).toContainText('<?xml version="1.0" encoding="UTF-8"?>');
await expect(xmlBlock).toContainText('<Component id="generic_sensor_1"');
await expect(xmlBlock).toContainText('schemaVersion="3"');
await expect(xmlBlock).toContainText('unitSystem="SI"');
await expect(xmlBlock).toContainText('sampleStep="0.1"');
await expect(xmlBlock).toContainText(
'<Component id="generic_sensor_1" type="generic_sensor" modelVersion="1.0.0"',
);
await expect(xmlBlock).toContainText('<Parameter name="gain" value="1"');
await expect(xmlBlock).not.toContainText("<Port");
await expect(xmlBlock).not.toContainText("componentType=");
await expect(xmlBlock).not.toContainText("rotation=");
await expect(xmlBlock).not.toContainText("mirrored=");
await expect(page.locator(".properties .xml-output")).toHaveCount(0);
await expect(page.locator(".properties")).not.toContainText("系统 XML");
await page.getByRole("button", { name: "收起仿真控制台" }).click();
await page.getByRole("button", { name: "检查模型" }).click();
await expect(consolePanel).toContainText(/模型检查/);
await expect(consolePanel).toHaveClass(/minimized/);
await expect(consolePanel).toHaveCSS("height", "40px");
await expect(consolePanel.locator(".simulation-console-dock-summary")).toContainText(
"模型检查",
);
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
await expect(page.locator(".status-strip")).toHaveCount(0);
await expect(page.locator(".validation-section")).toHaveCount(0);
await expect(page.locator(".properties")).not.toContainText("仿真结果");
});
test("XML 导出拒绝目录未定义的组件参数", async ({ page }) => {
await page.goto("/");
await page
.getByRole("button", { name: /通用测试元件/ })
.dragTo(page.locator(".flow-canvas .react-flow__pane"));
await page.getByRole("button", { name: "保存工程", exact: true }).click();
await page.evaluate(() => {
const key = "system-simulation-flow:project:demo-system";
const raw = window.localStorage.getItem(key);
if (!raw) {
throw new Error("测试工程未保存");
}
const project = JSON.parse(raw) as {
nodes: Array<{ data: { parameters: Record<string, number> } }>;
};
project.nodes[0].data.parameters.typoGain = 2;
window.localStorage.setItem(key, JSON.stringify(project));
});
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).toContainText("XML 生成失败");
await expect(consolePanel).toContainText("目录未定义的参数:typoGain");
await expect(consolePanel.getByLabel("生成的系统 XML")).toHaveCount(0);
});
test("XML 参数使用可往返的双精度文本", async ({ page }) => {
await page.goto("/");
await page
.getByRole("button", { name: /通用测试元件/ })
.dragTo(page.locator(".flow-canvas .react-flow__pane"));
await page.getByRole("button", { name: "保存工程", exact: true }).click();
await page.evaluate(() => {
const key = "system-simulation-flow:project:demo-system";
const raw = window.localStorage.getItem(key);
if (!raw) {
throw new Error("测试工程未保存");
}
const project = JSON.parse(raw) as {
nodes: Array<{ data: { parameters: Record<string, number> } }>;
};
project.nodes[0].data.parameters.gain = 1.2345678901234567;
window.localStorage.setItem(key, JSON.stringify(project));
});
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expect(page.getByLabel("生成的系统 XML")).toContainText(
'<Parameter name="gain" value="1.2345678901234567"',
);
});
test("拖入组件后仅选择新组件,变换操作不会继续作用于旧选择", async ({
page,
}) => {
@@ -91,6 +178,310 @@ test("拖入组件后仅选择新组件,变换操作不会继续作用于旧
);
});
test("复制后粘贴进入待放置状态,跟随鼠标并在左键确认时写入工程", async ({
page,
}) => {
await page.goto("/");
const pane = page.locator(".flow-canvas .react-flow__pane");
await page
.getByRole("button", { name: /通用测试元件/ })
.dragTo(pane, { targetPosition: { x: 250, y: 220 } });
const original = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"]',
);
await original.click();
await page.keyboard.press("Control+c");
const paneBounds = await pane.boundingBox();
expect(paneBounds).not.toBeNull();
const firstPointer = {
x: paneBounds!.x + paneBounds!.width * 0.62,
y: paneBounds!.y + paneBounds!.height * 0.54,
};
await page.mouse.move(firstPointer.x, firstPointer.y);
await page.keyboard.press("Control+v");
const preview = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_2"]',
);
await expect(preview).toHaveClass(/pending-paste-node/);
await expect(page.getByText(/待放置:左键放置/)).toBeVisible();
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(2);
await expect(page.getByLabel("组件名称")).toHaveCount(0);
await expect(page.getByRole("button", { name: "撤销 (Ctrl+Z)" })).toBeDisabled();
await expect(page.getByRole("button", { name: "复制 (Ctrl+C)" })).toBeDisabled();
await expect(page.getByRole("button", { name: "粘贴 (Ctrl+V)" })).toBeDisabled();
await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeDisabled();
const previewBeforeMove = await preview.boundingBox();
expect(previewBeforeMove).not.toBeNull();
await page.mouse.move(firstPointer.x + 90, firstPointer.y + 55, { steps: 4 });
await expect
.poll(async () => (await preview.boundingBox())?.x ?? 0)
.toBeGreaterThan(previewBeforeMove!.x + 40);
const viewport = page.locator(".flow-canvas .react-flow__viewport");
const viewportBeforeZoom = await viewport.getAttribute("style");
await page.keyboard.down("Control");
await page.mouse.wheel(0, -160);
await page.keyboard.up("Control");
await expect(viewport).not.toHaveAttribute("style", viewportBeforeZoom ?? "");
await expect(preview).toHaveClass(/pending-paste-node/);
const previewBeforeScroll = await preview.boundingBox();
expect(previewBeforeScroll).not.toBeNull();
const horizontalScrollbar = page.getByRole("scrollbar", {
name: "水平滚动画布",
});
const viewportBeforeScroll = await viewport.getAttribute("style");
await horizontalScrollbar.focus();
await page.keyboard.press("ArrowRight");
await expect(viewport).not.toHaveAttribute("style", viewportBeforeScroll ?? "");
await expect(preview).toHaveClass(/pending-paste-node/);
const previewAfterScroll = await preview.boundingBox();
expect(previewAfterScroll).not.toBeNull();
expect(
Math.abs(
previewAfterScroll!.x +
previewAfterScroll!.width / 2 -
(previewBeforeScroll!.x + previewBeforeScroll!.width / 2),
),
).toBeLessThanOrEqual(10);
expect(
Math.abs(
previewAfterScroll!.y +
previewAfterScroll!.height / 2 -
(previewBeforeScroll!.y + previewBeforeScroll!.height / 2),
),
).toBeLessThanOrEqual(10);
const verticalBefore = await preview.locator(".sim-node").evaluate((element) =>
element.classList.contains("orientation-vertical"),
);
const previewBounds = await preview.boundingBox();
expect(previewBounds).not.toBeNull();
await page.mouse.click(
previewBounds!.x + previewBounds!.width / 2,
previewBounds!.y + previewBounds!.height / 2,
{ button: "middle" },
);
await expect
.poll(() =>
preview.locator(".sim-node").evaluate((element) =>
element.classList.contains("orientation-vertical"),
),
)
.not.toBe(verticalBefore);
const finalBounds = await preview.boundingBox();
expect(finalBounds).not.toBeNull();
await page.mouse.click(
finalBounds!.x + finalBounds!.width / 2,
finalBounds!.y + finalBounds!.height / 2,
);
await expect(preview).not.toHaveClass(/pending-paste-node/);
await expect(preview).toHaveClass(/selected/);
await expect(page.getByLabel("组件名称")).toHaveValue("generic_sensor_1_副本");
});
test("复制已连接组件时副本 ID 避让画布中的同名节点且不会移动原元件", async ({
page,
}) => {
const project = structuredClone(wideProject);
project.nodes = project.nodes.slice(0, 2);
project.edges = project.edges.slice(0, 1);
project.nodes[1] = {
...project.nodes[1],
data: {
...project.nodes[1].data,
label: "amesim_pneumatic_test_1",
componentType: "amesim_pneumatic_test",
modelType: "amesim_pneumatic_test",
},
};
await page.addInitScript((storedProject) => {
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(storedProject),
);
}, project);
await page.goto("/");
await page.getByRole("button", { name: "加载工程" }).click();
const firstNode = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"]',
);
const occupiedIdNode = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_2"]',
);
await expect(firstNode).toBeVisible();
await expect(occupiedIdNode).toBeVisible();
const firstBoundsBefore = await firstNode.boundingBox();
const occupiedBoundsBefore = await occupiedIdNode.boundingBox();
expect(firstBoundsBefore).not.toBeNull();
expect(occupiedBoundsBefore).not.toBeNull();
await firstNode.click();
await page.keyboard.press("Control+c");
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 * 0.62,
paneBounds!.y + paneBounds!.height * 0.68,
);
await page.keyboard.press("Control+v");
const preview = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_3"]',
);
await expect(preview).toHaveClass(/pending-paste-node/);
await expect(preview.locator(".node-label")).toHaveText(
"generic_sensor_1_副本",
);
await expect(occupiedIdNode).not.toHaveClass(/pending-paste-node/);
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(3);
const previewBounds = await preview.boundingBox();
expect(previewBounds).not.toBeNull();
await page.mouse.click(
previewBounds!.x + previewBounds!.width / 2,
previewBounds!.y + previewBounds!.height / 2,
);
await expect(preview).not.toHaveClass(/pending-paste-node/);
await expect(preview).toHaveClass(/selected/);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
const firstBoundsAfter = await firstNode.boundingBox();
const occupiedBoundsAfter = await occupiedIdNode.boundingBox();
expect(firstBoundsAfter).toEqual(firstBoundsBefore);
expect(occupiedBoundsAfter).toEqual(occupiedBoundsBefore);
const nodeIds = await page
.locator(".flow-canvas .react-flow__node")
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-id")));
expect(new Set(nodeIds).size).toBe(nodeIds.length);
});
test("控制台文本选择使用浏览器原生复制,不触发元件复制", async ({ page }) => {
await page.goto("/");
await page
.getByRole("button", { name: /通用测试元件/ })
.dragTo(page.locator(".flow-canvas .react-flow__pane"));
const node = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"]',
);
await node.click();
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await page.getByRole("button", { name: "生成系统 XML" }).click();
const xml = page.getByLabel("生成的系统 XML");
await expect(xml).toBeVisible();
await xml.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
});
const copiedModelMessagesBefore = await page
.locator(".simulation-console-dock-entry-message", { hasText: "已复制" })
.count();
await page.keyboard.press("Control+c");
await expect(page.locator(".flow-canvas .pending-paste-node")).toHaveCount(0);
await expect
.poll(() =>
page
.locator(".simulation-console-dock-entry-message", { hasText: "已复制" })
.count(),
)
.toBe(copiedModelMessagesBefore);
});
test("底部仿真控制台可以调整高度并折叠显示摘要", async ({ page }) => {
await page.goto("/");
const workbench = page.locator(".workbench");
const palette = page.locator(".palette");
const canvas = page.locator(".flow-canvas");
const properties = page.locator(".properties");
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
const expectClose = (actual: number, expected: number, tolerance = 2) => {
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance);
};
await expect(consolePanel).toHaveClass(/minimized/);
await expect(consolePanel).toHaveCSS("height", "40px");
const initialWorkbench = await workbench.boundingBox();
const initialPalette = await palette.boundingBox();
const initialCanvas = await canvas.boundingBox();
const initialProperties = await properties.boundingBox();
const initialConsole = await consolePanel.boundingBox();
expect(initialWorkbench).not.toBeNull();
expect(initialPalette).not.toBeNull();
expect(initialCanvas).not.toBeNull();
expect(initialProperties).not.toBeNull();
expect(initialConsole).not.toBeNull();
expectClose(initialConsole!.x, initialCanvas!.x);
expectClose(initialConsole!.width, initialCanvas!.width);
expectClose(initialConsole!.y, initialCanvas!.y + initialCanvas!.height);
expectClose(
initialConsole!.y + initialConsole!.height,
initialWorkbench!.y + initialWorkbench!.height,
);
expectClose(initialPalette!.y, initialWorkbench!.y);
expectClose(initialPalette!.height, initialWorkbench!.height);
expectClose(initialProperties!.y, initialWorkbench!.y);
expectClose(initialProperties!.height, initialWorkbench!.height);
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toHaveClass(/normal/);
const expandedCanvas = await canvas.boundingBox();
const expandedConsole = await consolePanel.boundingBox();
const expandedPalette = await palette.boundingBox();
const expandedProperties = await properties.boundingBox();
expect(expandedCanvas).not.toBeNull();
expect(expandedConsole).not.toBeNull();
expect(expandedPalette).not.toBeNull();
expect(expandedProperties).not.toBeNull();
expect(expandedCanvas!.height).toBeLessThan(initialCanvas!.height - 100);
expectClose(expandedConsole!.x, expandedCanvas!.x);
expectClose(expandedConsole!.width, expandedCanvas!.width);
expectClose(expandedPalette!.y, initialPalette!.y);
expectClose(expandedPalette!.height, initialPalette!.height);
expectClose(expandedProperties!.y, initialProperties!.y);
expectClose(expandedProperties!.height, initialProperties!.height);
const resizer = page.getByRole("separator", {
name: "调整仿真控制台高度",
});
const resizerBox = await resizer.boundingBox();
expect(resizerBox).not.toBeNull();
await page.mouse.move(resizerBox!.x + resizerBox!.width / 2, resizerBox!.y + 3);
await page.mouse.down();
await page.mouse.move(resizerBox!.x + resizerBox!.width / 2, resizerBox!.y - 55, {
steps: 5,
});
await page.mouse.up();
await expect
.poll(async () => (await consolePanel.boundingBox())?.height ?? 0)
.toBeGreaterThan(expandedConsole!.height + 35);
const resizedConsole = await consolePanel.boundingBox();
const resizedCanvas = await canvas.boundingBox();
expect(resizedConsole).not.toBeNull();
expect(resizedCanvas).not.toBeNull();
expectClose(
resizedConsole!.height - expandedConsole!.height,
expandedCanvas!.height - resizedCanvas!.height,
3,
);
await page.getByRole("button", { name: "收起仿真控制台" }).click();
await expect(consolePanel).toHaveCSS("height", "40px");
await expect(consolePanel.locator(".simulation-console-dock-summary")).not.toBeEmpty();
const collapsedCanvas = await canvas.boundingBox();
expect(collapsedCanvas).not.toBeNull();
expectClose(collapsedCanvas!.height, initialCanvas!.height);
});
test("框选结束后隐藏整体包围框并保留每个组件的虚线选中框", async ({
page,
}) => {
+127 -38
View File
@@ -28,18 +28,116 @@ async function expectFixedIconCardSize(
expect(iconBox!.height).toBeLessThanOrEqual(49);
}
test("组件库可切换详细与仅图标显示,并在刷新后保留选择", async ({ page }) => {
test("组件库标题、库分组和类别分组保持清晰的三级字号", async ({ page }) => {
await page.goto("/");
const paletteTitle = page.locator(".palette-panel-title > span");
const libraryTitle = page.locator(".palette-library-heading strong").first();
const categoryTitle = page.locator(".palette-category-title > span").first();
await expect(paletteTitle).toHaveCSS("font-size", "16px");
await expect(paletteTitle).toHaveCSS("font-weight", "700");
await expect(libraryTitle).toHaveCSS("font-size", "14px");
await expect(libraryTitle).toHaveCSS("font-weight", "700");
await expect(categoryTitle).toHaveCSS("font-size", "12px");
await expect(categoryTitle).toHaveCSS("font-weight", "600");
const [paletteSize, librarySize, categorySize] = await Promise.all(
[paletteTitle, libraryTitle, categoryTitle].map((locator) =>
locator.evaluate((element) => parseFloat(getComputedStyle(element).fontSize)),
),
);
expect(paletteSize).toBeGreaterThan(librarySize);
expect(librarySize).toBeGreaterThan(categorySize);
});
test("组件目录成功时显示绿色状态且隐藏临时测试组件库", async ({ page }) => {
await page.goto("/");
const status = page.locator(".catalog-source");
await expect(status).toHaveClass(/ready/);
await expect(status).toHaveText("已加载 1 个组件库");
await expect(status).toHaveAttribute("title", "已加载 1 个组件库");
await expect(status.locator(".catalog-source-dot")).toHaveCSS(
"background-color",
"rgb(25, 135, 84)",
);
await expect(page.getByText("临时测试组件库", { exact: true })).toHaveCount(0);
await expect(
page.getByRole("button", { name: "自动化测试组件库", exact: true }),
).toBeVisible();
});
test("组件目录失败时不显示兜底库并在状态悬停时说明失败范围", async ({
page,
}) => {
await page.unroute("**/api/components/catalog");
await page.route("**/api/components/catalog", async (route) => {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "catalog unavailable" }),
});
});
await page.goto("/");
const status = page.locator(".catalog-source");
await expect(status).toHaveClass(/error/);
await expect(status).toContainText("加载失败");
await expect(status.locator(".catalog-source-dot")).toHaveCSS(
"background-color",
"rgb(197, 48, 48)",
);
await expect(page.locator("aside.palette .palette-library")).toHaveCount(0);
await expect(page.locator("aside.palette .palette-item")).toHaveCount(0);
await expect(page.getByText("临时测试组件库", { exact: true })).toHaveCount(0);
await status.hover();
const detail = page.getByRole("tooltip");
await expect(detail).toBeVisible();
await expect(detail).toContainText("AMESim 组件库");
await expect(detail).toContainText("503");
});
test("组件目录格式错误时在悬停详情中指出失败的组件库", async ({ page }) => {
await page.unroute("**/api/components/catalog");
await page.route("**/api/components/catalog", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
schemaVersion: 1,
libraries: [
{
id: "broken-library",
label: "损坏组件库",
components: [],
},
],
}),
});
});
await page.goto("/");
const status = page.locator(".catalog-source");
await expect(status).toHaveClass(/error/);
await status.hover();
await expect(page.getByRole("tooltip")).toContainText(
"加载失败的组件库:损坏组件库",
);
await expect(page.locator("aside.palette .palette-library")).toHaveCount(0);
});
test("组件库始终使用图标布局并忽略旧的列表模式设置", async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem(
"system-simulation-flow:palette-display-mode",
"detailed",
);
});
await page.goto("/");
const palette = page.locator("aside.palette");
const detailedButton = page.getByRole("button", {
name: "详细显示",
exact: true,
});
const iconsButton = page.getByRole("button", {
name: "仅图标显示",
exact: true,
});
const airItem = palette.getByRole("button", {
name: "空气介质定义",
exact: true,
@@ -49,9 +147,13 @@ test("组件库可切换详细与仅图标显示,并在刷新后保留选择",
exact: true,
});
await expect(palette).toHaveAttribute("data-view-mode", "detailed");
await expect(detailedButton).toHaveAttribute("aria-pressed", "true");
await expect(iconsButton).toHaveAttribute("aria-pressed", "false");
await expect(palette).not.toHaveAttribute("data-view-mode");
await expect(
page.getByRole("group", { name: "组件库显示方式" }),
).toHaveCount(0);
await expect(page.getByRole("button", { name: "详细显示" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "仅图标显示" })).toHaveCount(0);
await expect(page.getByText("临时测试组件库", { exact: true })).toHaveCount(0);
await expect(airItem.locator(".palette-item-copy")).toBeVisible();
const airSymbol = airItem.locator(
@@ -76,22 +178,19 @@ test("组件库可切换详细与仅图标显示,并在刷新后保留选择",
.locator('[data-component-symbol="generic"]');
await expect(unknownSymbol.locator("svg")).toBeVisible();
await iconsButton.click();
await expect(palette).toHaveAttribute("data-view-mode", "icons");
await expect(detailedButton).toHaveAttribute("aria-pressed", "false");
await expect(iconsButton).toHaveAttribute("aria-pressed", "true");
await expect(airItem).toBeVisible();
await expect(airItem.locator(".palette-item-name")).toBeVisible();
await expect(airItem.locator(".palette-item-name")).toHaveText("空气介质定义");
await expect(airItem.locator(".palette-item-meta")).toBeHidden();
await expect(airItem.locator(".palette-item-meta")).toHaveCount(0);
await expect(airItem).not.toHaveAttribute("title");
await expectFixedIconCardSize(airItem);
await page.reload();
await expect(palette).toHaveAttribute("data-view-mode", "icons");
await expect(iconsButton).toHaveAttribute("aria-pressed", "true");
await expect(palette).not.toHaveAttribute("data-view-mode");
await expect(page.getByRole("button", { name: "详细显示" })).toHaveCount(0);
await expect(airItem.locator(".palette-item-name")).toBeVisible();
await expect(airItem.locator(".palette-item-meta")).toBeHidden();
await expect(airItem.locator(".palette-item-meta")).toHaveCount(0);
});
test("组件库和分类支持独立折叠并同步可访问状态", async ({ page }) => {
@@ -146,11 +245,8 @@ test("组件库和分类支持独立折叠并同步可访问状态", async ({ pa
).toHaveAttribute("aria-expanded", "true");
});
test("仅图标模式名称截断且卡片尺寸不随侧栏和视口改变", async ({ page }) => {
test("图标名称截断且卡片尺寸不随侧栏和视口改变", async ({ page }) => {
await page.goto("/");
await page
.getByRole("button", { name: "仅图标显示", exact: true })
.click();
const palette = page.locator("aside.palette");
const longNameItem = palette.getByRole("button", {
@@ -162,7 +258,7 @@ test("仅图标模式名称截断且卡片尺寸不随侧栏和视口改变", as
await expect(name).toBeVisible();
await expect(name).toHaveText("AMESim 气动测试元件");
await expect(meta).toBeHidden();
await expect(meta).toHaveCount(0);
await expect(longNameItem).not.toHaveAttribute("title");
const truncation = await name.evaluate((element) => {
const style = window.getComputedStyle(element);
@@ -204,11 +300,8 @@ test("仅图标模式名称截断且卡片尺寸不随侧栏和视口改变", as
await expectFixedIconCardSize(longNameItem);
});
test("仅图标模式悬停后显示完整名称和图标预览并在移开后关闭", async ({ page }) => {
test("图标悬停后显示完整名称和预览并在移开后关闭", async ({ page }) => {
await page.goto("/");
await page
.getByRole("button", { name: "仅图标显示", exact: true })
.click();
const item = page.locator("aside.palette").getByRole("button", {
name: "AMESim 气动测试元件",
@@ -234,13 +327,9 @@ test("仅图标模式悬停后显示完整名称和图标预览并在移开后
await expect(item).not.toHaveAttribute("aria-describedby", "palette-icon-preview");
});
test("仅图标模式仍可拖入氦气介质并在画布显示专用图标", async ({ page }) => {
test("图标布局仍可拖入氦气介质并在画布显示专用图标", async ({ page }) => {
await page.goto("/");
await page
.getByRole("button", { name: "仅图标显示", exact: true })
.click();
const palette = page.locator("aside.palette");
const heliumItem = palette.getByRole("button", {
name: "氦气介质定义",
@@ -292,7 +381,7 @@ test("从组件库拖入时使用工作区图标作为待拖入预览", async ({
__componentDragPreview?: {
backgroundColor: string;
borderTopWidth: string;
hasLegacyCardLabel: boolean;
hasCardLabel: boolean;
height: number;
hotspotX: number;
hotspotY: number;
@@ -305,7 +394,7 @@ test("从组件库拖入时使用工作区图标作为待拖入预览", async ({
).__componentDragPreview = {
backgroundColor: style.backgroundColor,
borderTopWidth: style.borderTopWidth,
hasLegacyCardLabel: Boolean(
hasCardLabel: Boolean(
image.querySelector(".component-drag-preview-label"),
),
height: bounds.height,
@@ -336,7 +425,7 @@ test("从组件库拖入时使用工作区图标作为待拖入预览", async ({
__componentDragPreview?: {
backgroundColor: string;
borderTopWidth: string;
hasLegacyCardLabel: boolean;
hasCardLabel: boolean;
height: number;
hotspotX: number;
hotspotY: number;
@@ -352,7 +441,7 @@ test("从组件库拖入时使用工作区图标作为待拖入预览", async ({
expect(preview!.rootClassName).toContain("component-drag-preview");
expect(preview!.symbolClassName).toContain("component-symbol-canvas");
expect(preview!.symbolClassName).not.toContain("component-symbol-palette");
expect(preview!.hasLegacyCardLabel).toBe(false);
expect(preview!.hasCardLabel).toBe(false);
expect(preview!.borderTopWidth).toBe("0px");
expect(preview!.backgroundColor).toBe("rgba(0, 0, 0, 0)");
expect(Math.abs(preview!.hotspotX - preview!.width / 2)).toBeLessThanOrEqual(0.5);
+188 -20
View File
@@ -1,6 +1,6 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { prepareApp } from "./fixtures";
import { expandSimulationConsole, prepareApp } from "./fixtures";
test.beforeEach(async ({ page }) => {
await prepareApp(page);
@@ -695,7 +695,7 @@ test("MECMAS21 参数组折叠和条件切换保留值并清理详情浮层", as
);
});
test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本", async ({
test("MECMAS21 当前参数编码由工程版本统一并写入 XML", async ({
page,
}) => {
await page.goto("/");
@@ -709,7 +709,7 @@ test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本",
await expect(frictionGroup).toHaveCount(0);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const savedEncodingVersions = await page.evaluate(() =>
const savedContracts = await page.evaluate(() =>
Object.keys(window.localStorage)
.filter((key) => key.startsWith("system-simulation-flow:project:"))
.flatMap((key) => {
@@ -718,24 +718,32 @@ test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本",
return [];
}
const project = JSON.parse(raw) as {
amesimParameterEncodingVersion?: number | null;
nodes?: Array<{ id?: string }>;
projectSchemaVersion?: number;
nodes?: Array<{ id?: string; data?: { modelVersion?: string } }>;
};
return project.nodes?.some(
const node = project.nodes?.find(
(node) => node.id === "amesim_mecmas21_1",
)
? [project.amesimParameterEncodingVersion]
);
return node
? [{
projectSchemaVersion: project.projectSchemaVersion,
modelVersion: node.data?.modelVersion,
}]
: [];
}),
);
expect(savedEncodingVersions).toContain(1);
expect(savedContracts).toContainEqual({
projectSchemaVersion: 1,
modelVersion: "0.2.0",
});
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
await expect(page.getByLabel("生成的系统 XML")).toContainText(
'amesimParameterEncodingVersion="1"',
'type="amesim_mecmas21" modelVersion="0.2.0"',
);
const migratedProjectCount = await page.evaluate(() => {
const changedProjectCount = await page.evaluate(() => {
let changed = 0;
Object.keys(window.localStorage)
.filter((key) =>
@@ -750,8 +758,6 @@ test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本",
return;
}
const project = JSON.parse(raw) as {
mediumReferenceVersion?: number | null;
amesimParameterEncodingVersion?: number | null;
nodes?: Array<{
id?: string;
data?: {
@@ -766,18 +772,13 @@ test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本",
if (!node?.data?.parameters) {
return;
}
project.amesimParameterEncodingVersion = null;
project.mediumReferenceVersion = null;
delete node.data.modelType;
node.data.parameters.useFriction = 0;
delete node.data.parameters.strib;
node.data.parameters.unrelatedCode = 9;
window.localStorage.setItem(key, JSON.stringify(project));
changed += 1;
});
return changed;
});
expect(migratedProjectCount).toBeGreaterThanOrEqual(2);
expect(changedProjectCount).toBeGreaterThanOrEqual(2);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await page
@@ -791,6 +792,7 @@ test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本",
const frictionType = page.getByLabel("摩擦类型", { exact: true });
await frictionType.selectOption("2");
const strib = page.getByLabel("Stribeck 选项", { exact: true });
await strib.selectOption("2");
await expect(strib).toHaveValue("2");
await expect(strib.locator("option:checked")).toHaveText("启用");
await expect(page.getByLabel("无关编码", { exact: true })).toHaveValue("9");
@@ -813,6 +815,166 @@ test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本",
await expect(strib).toHaveValue("2");
});
test("MECMAS21 旧的 0/1 编码不转换并阻止执行", async ({
page,
}) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
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<{
id?: string;
data?: {
parameters?: Record<string, number | string>;
};
}>;
};
const node = project.nodes?.find(
(candidate) => candidate.id === "amesim_mecmas21_1",
);
if (!node?.data?.parameters) {
return false;
}
node.data.parameters.useFriction = 0;
window.localStorage.setItem(key, JSON.stringify(project));
return true;
});
expect(changed).toBe(true);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
const node = page.locator(
'.flow-canvas .react-flow__node[data-id="amesim_mecmas21_1"]',
);
await expect(node.locator('[data-model-contract-compatible="true"]')).toBeVisible();
await node.click();
const useFriction = page.getByLabel("启用摩擦", { exact: true });
await expect(useFriction).toHaveValue("");
await expect(useFriction.locator("option:checked")).toHaveText(
"当前选项无效,请重新选择",
);
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await page.getByRole("button", { name: "检查模型", exact: true }).click();
await expect(consolePanel).toHaveClass(/minimized/);
await expect(
consolePanel.locator(".simulation-console-dock-summary"),
).toContainText("模型检查发现");
await expandSimulationConsole(page);
await expect(consolePanel.locator(".simulation-console-dock-log")).toContainText(
"useFriction=0",
);
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
await expect(consolePanel).toContainText("不能生成执行 XML");
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await expect(consolePanel).toContainText("模型检查发现");
});
test("缺失模型版本的工程可查看但不补默认参数且不能执行", async ({
page,
}) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
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<{
id?: string;
data?: {
modelVersion?: string;
parameters?: Record<string, number | string>;
};
}>;
};
const node = project.nodes?.find(
(candidate) => candidate.id === "amesim_mecmas21_1",
);
if (!node?.data?.parameters) {
return false;
}
delete node.data.modelVersion;
delete node.data.parameters.strib;
window.localStorage.setItem(key, JSON.stringify(project));
return true;
});
expect(changed).toBe(true);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
const node = page.locator(
'.flow-canvas .react-flow__node[data-id="amesim_mecmas21_1"]',
);
await expect(node.locator('[data-model-contract-compatible="false"]')).toBeVisible();
await expect(node.getByLabel(/模型合同不兼容/)).toBeVisible();
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const persistedWithoutDefaults = 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 (!raw) {
return false;
}
const project = JSON.parse(raw) as {
nodes?: Array<{
id?: string;
data?: {
modelVersion?: string;
parameters?: Record<string, number | string>;
};
}>;
};
const data = project.nodes?.find(
(candidate) => candidate.id === "amesim_mecmas21_1",
)?.data;
return Boolean(
data &&
data.modelVersion === undefined &&
data.parameters &&
!("strib" in data.parameters),
);
});
expect(persistedWithoutDefaults).toBe(true);
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await page.getByRole("button", { name: "检查模型", exact: true }).click();
await expect(consolePanel).toHaveClass(/minimized/);
await expect(
consolePanel.locator(".simulation-console-dock-summary"),
).toContainText("模型检查发现");
await expandSimulationConsole(page);
await expect(consolePanel.locator(".simulation-console-dock-log")).toContainText(
"缺少 modelVersion",
);
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
await expect(consolePanel).toContainText("不能生成执行 XML");
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await expect(consolePanel).toContainText("模型检查发现");
});
test("详情浮层只在参数列悬停且下拉选择期间不触发", async ({ page }) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
@@ -995,6 +1157,7 @@ test("参数说明只在参数列悬停显示,单位切换不改变 XML 基础
);
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
@@ -1056,6 +1219,7 @@ test("科学计数法在提交、单位换算和工程加载后保持", async ({
await expect(temperatureInput).toHaveValue("1e-10");
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
await expect(page.getByLabel("生成的系统 XML")).toContainText(
'<Parameter name="reference_pressure" value="1200000"',
);
@@ -1235,6 +1399,7 @@ test("组件参数公式保留原式,并在说明中显示计算值且 XML 只
);
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
@@ -1275,6 +1440,7 @@ test("非法组件参数公式显示错误并阻止 XML 生成", async ({ page }
).toBeVisible();
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
@@ -1305,6 +1471,7 @@ test("仿真数值设置同样保留公式,并在执行协议中使用计算
).toContainText("7 s");
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
const xmlBlock = page.getByLabel("生成的系统 XML");
await expect(xmlBlock).toContainText('tStop="7"');
await expect(xmlBlock).not.toContainText(expression);
@@ -1330,7 +1497,8 @@ test("仿真数值设置中的科学计数法在保存加载后保持", async ({
await expect(stepInput).toHaveValue("1e-5");
await expect(maxStepInput).toHaveValue("1.20E-4");
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
const xmlBlock = page.getByLabel("生成的系统 XML");
await expect(xmlBlock).toContainText('step="0.00001"');
await expect(xmlBlock).toContainText('sampleStep="0.00001"');
await expect(xmlBlock).toContainText('maxStep="0.00012"');
});