完善MECMAS21参数配置与分组交互

This commit is contained in:
ljz committed 2026-08-03 23:51:27 +08:00
1 parent 32f21f08ff
commit be54070855
28 files changed
+3418 -202

No files matched your search

+361 -72
View File
@@ -95,6 +95,7 @@ import {
loadParameterColumnLayout,
saveParameterColumnLayout,
type ParameterColumnLayout,
type ParameterTableGroup,
type ParameterTableRow,
} from "./ParameterTable";
import {
@@ -174,8 +175,9 @@ type ParameterDefinition = {
min?: number;
max?: number;
minExclusive?: boolean;
editor?: "amesimGasReference" | "amesimGasPropertyModel";
editor?: "choice" | "amesimGasReference" | "amesimGasPropertyModel";
options?: ParameterOption[];
visibleWhen?: ParameterVisibilityCondition[];
};
type ParameterOption = {
@@ -183,6 +185,19 @@ type ParameterOption = {
label: string;
};
type ParameterVisibilityCondition = {
parameter: string;
values: number[];
};
type ParameterGroupDefinition = {
id: string;
label: string;
order: number;
parameters: string[];
defaultExpanded: boolean;
};
type ComponentDefinition = {
type: string;
label: string;
@@ -198,6 +213,7 @@ type ComponentDefinition = {
role?: "amesimGasMediumDefinition";
ports: PortDefinition[];
parameters: Record<string, ParameterDefinition>;
parameterGroups?: ParameterGroupDefinition[];
};
type ComponentLibraryDefinition = {
@@ -220,8 +236,9 @@ type ComponentCatalogParameterPayload = {
minimum?: number;
maximum?: number;
minimumExclusive: boolean;
editor?: "amesimGasReference" | "amesimGasPropertyModel";
editor?: "choice" | "amesimGasReference" | "amesimGasPropertyModel";
options?: ParameterOption[];
visibleWhen?: ParameterVisibilityCondition[];
};
type ComponentCatalogComponentPayload = {
@@ -239,6 +256,13 @@ type ComponentCatalogComponentPayload = {
role?: "amesimGasMediumDefinition";
ports: Array<PortDefinition & { order: number }>;
parameters: ComponentCatalogParameterPayload[];
parameterGroups?: Array<{
id: string;
label: string;
order: number;
parameters: string[];
defaultExpanded?: boolean;
}>;
};
type ComponentCatalogResponse = {
@@ -313,7 +337,8 @@ type ProjectEdgePayload = {
type ReactFlowProjectPayload = {
name: string;
mediumReferenceVersion?: 1;
mediumReferenceVersion?: 1 | null;
amesimParameterEncodingVersion?: 1 | null;
nodes: ProjectNodePayload[];
edges: ProjectEdgePayload[];
simulation: SimulationConfig;
@@ -1055,6 +1080,58 @@ function normalizeComponentCatalog(payload: unknown): ComponentLibraryDefinition
}
componentTypes.add(component.type);
const parameters = Object.fromEntries(
component.parameters.map((parameter) => {
const visibleWhen = normalizeParameterVisibilityConditions(
parameter.visibleWhen,
);
return [
parameter.name,
{
label: parameter.label || parameter.name,
...(typeof parameter.description === "string" &&
parameter.description.trim()
? { description: parameter.description.trim() }
: {}),
...(parameter.unit ? { unit: parameter.unit } : {}),
...(parameter.quantity && parameter.quantity !== "dimensionless"
? { quantity: parameter.quantity }
: {}),
default: parameter.default,
...(parameter.minimum !== undefined
? { min: parameter.minimum }
: {}),
...(parameter.maximum !== undefined
? { max: parameter.maximum }
: {}),
...(parameter.minimumExclusive
? { minExclusive: true }
: {}),
...(parameter.editor === "choice" ||
parameter.editor === "amesimGasReference" ||
parameter.editor === "amesimGasPropertyModel"
? { editor: parameter.editor }
: {}),
...(Array.isArray(parameter.options)
? {
options: parameter.options.flatMap((option) =>
Number.isFinite(Number(option?.value)) &&
typeof option?.label === "string"
? [{ value: Number(option.value), label: option.label }]
: [],
),
}
: {}),
...(visibleWhen ? { visibleWhen } : {}),
},
] as const;
}),
);
const parameterGroups = normalizeParameterGroups(
component.parameterGroups,
new Set(Object.keys(parameters)),
);
return {
type: component.type,
label: component.label,
@@ -1080,46 +1157,8 @@ function normalizeComponentCatalog(payload: unknown): ComponentLibraryDefinition
: {}),
side: port.side,
})),
parameters: Object.fromEntries(
component.parameters.map((parameter) => [
parameter.name,
{
label: parameter.label || parameter.name,
...(typeof parameter.description === "string" &&
parameter.description.trim()
? { description: parameter.description.trim() }
: {}),
...(parameter.unit ? { unit: parameter.unit } : {}),
...(parameter.quantity && parameter.quantity !== "dimensionless"
? { quantity: parameter.quantity }
: {}),
default: parameter.default,
...(parameter.minimum !== undefined
? { min: parameter.minimum }
: {}),
...(parameter.maximum !== undefined
? { max: parameter.maximum }
: {}),
...(parameter.minimumExclusive
? { minExclusive: true }
: {}),
...(parameter.editor === "amesimGasReference" ||
parameter.editor === "amesimGasPropertyModel"
? { editor: parameter.editor }
: {}),
...(Array.isArray(parameter.options)
? {
options: parameter.options.flatMap((option) =>
Number.isFinite(Number(option?.value)) &&
typeof option?.label === "string"
? [{ value: Number(option.value), label: option.label }]
: [],
),
}
: {}),
},
]),
),
parameters,
...(parameterGroups.length > 0 ? { parameterGroups } : {}),
} satisfies ComponentDefinition;
});
@@ -1193,11 +1232,44 @@ function defaultParameterUnits(definition: ComponentDefinition) {
);
}
function orderedParameterEntries(definition: ComponentDefinition) {
return Object.entries(definition.parameters).sort(
([firstKey], [secondKey]) =>
Number(secondKey === "gi") - Number(firstKey === "gi"),
function ungroupedParameterPriority(
key: string,
definition: ParameterDefinition,
) {
if (key === "gi") {
return 0;
}
return definition.editor ? 1 : 2;
}
function orderedParameterEntries(
definition: ComponentDefinition,
): Array<[string, ParameterDefinition]> {
const entries = Object.entries(definition.parameters);
const entryByName = new Map(entries);
const groupedNames = new Set(
definition.parameterGroups?.flatMap((group) => group.parameters) ?? [],
);
const ungroupedEntries = entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry: [key] }) => !groupedNames.has(key))
.sort(
(first, second) =>
ungroupedParameterPriority(first.entry[0], first.entry[1]) -
ungroupedParameterPriority(second.entry[0], second.entry[1]) ||
first.index - second.index,
)
.map(({ entry }) => entry);
const groupedEntries =
definition.parameterGroups?.flatMap((group) =>
group.parameters.flatMap((name) => {
const parameter = entryByName.get(name);
return parameter
? ([[name, parameter]] as Array<[string, ParameterDefinition]>)
: [];
}),
) ?? [];
return [...ungroupedEntries, ...groupedEntries];
}
function buildNodeData(definition: ComponentDefinition): SimulationNodeData {
@@ -1392,9 +1464,9 @@ function repairAmesimGasReferences(
function migrateLegacyDefaultAmesimGasReference(
nodes: SimulationNode[],
componentDefinitions: ComponentDefinition[],
mediumReferenceVersion: 1 | undefined,
mediumReferenceVersion: 1 | null | undefined,
): AmesimGasReferenceRepair {
const hasVersionMarker = mediumReferenceVersion !== undefined;
const hasVersionMarker = mediumReferenceVersion === 1;
const hasMediumDefinition = nodes.some((node) =>
isAmesimGasMediumDefinition(
componentDefinitionForNode(node, componentDefinitions),
@@ -4260,7 +4332,11 @@ function FlowWorkbench() {
recordHistory();
}
const normalizedNodes = project.nodes.map((node) =>
normalizeLoadedNode(node, componentDefinitions),
normalizeLoadedNode(
node,
componentDefinitions,
project.amesimParameterEncodingVersion,
),
);
const indexedMedia = assignMissingAmesimGasDefinitionIndices(
normalizedNodes,
@@ -4993,8 +5069,11 @@ function FlowWorkbench() {
readOnly: true,
description: "系统为画布节点分配的稳定标识,用于工程数据和连接关系。",
},
...orderedParameterEntries(selectedDefinition).map(
([key, definition]): ParameterTableRow => {
...orderedParameterEntries(selectedDefinition)
.filter(([, definition]) =>
parameterIsVisible(definition, selectedNode.data.parameters),
)
.map(([key, definition]): ParameterTableRow => {
const selectedUnit =
selectedNode.data.parameterUnits[key] ?? definition.unit ?? "";
const units = parameterUnitOptions(definition);
@@ -5012,6 +5091,7 @@ function FlowWorkbench() {
definition.editor === "amesimGasReference";
const gasPropertyModel =
definition.editor === "amesimGasPropertyModel";
const genericChoice = definition.editor === "choice";
const parameterLabel = mediumDefinitionIndex
? "介质定义索引"
: gasReference
@@ -5025,21 +5105,20 @@ function FlowWorkbench() {
amesimGasMediumOptions.some(
(option) => option.value === gasReferenceIndex,
);
const propertyModelValue = Number(baseValue);
const propertyModelAvailable =
gasPropertyModel &&
Number.isFinite(propertyModelValue) &&
const optionValue = numericChoiceValue(baseValue);
const optionAvailable =
(gasPropertyModel || genericChoice) &&
optionValue !== null &&
(definition.options ?? []).some(
(option) => option.value === propertyModelValue,
(option) => option.value === optionValue,
);
const error =
gasReference && !gasReferenceAvailable
? "引用的介质定义不存在"
: parameterValidationMessage(
definition,
baseValue,
selectedUnit,
);
componentParameterValidationMessage(
definition,
baseValue,
selectedUnit,
gasReference ? amesimGasMediumOptions : undefined,
);
let valueControl: ReactNode;
if (mediumDefinitionIndex) {
valueControl = (
@@ -5076,7 +5155,7 @@ function FlowWorkbench() {
))}
</select>
);
} else if (gasPropertyModel) {
} else if (gasPropertyModel || genericChoice) {
valueControl = (
<select
aria-invalid={Boolean(error)}
@@ -5087,12 +5166,14 @@ function FlowWorkbench() {
}
onFocus={recordHistory}
value={
propertyModelAvailable ? String(propertyModelValue) : ""
optionAvailable ? String(optionValue) : ""
}
>
{!propertyModelAvailable ? (
{!optionAvailable ? (
<option disabled value="">
当前模型无效,请重新选择
{gasPropertyModel
? "当前模型无效,请重新选择"
: "当前选项无效,请重新选择"}
</option>
) : null}
{(definition.options ?? []).map((option) => (
@@ -5160,10 +5241,61 @@ function FlowWorkbench() {
selectedUnit,
),
};
},
),
}),
]
: [];
const componentParameterGroups: ParameterTableGroup[] =
selectedNode && selectedDefinition
? (selectedDefinition.parameterGroups ?? []).flatMap((group) => {
const rowIds = group.parameters.flatMap((name) => {
const definition = selectedDefinition.parameters[name];
return definition &&
parameterIsVisible(definition, selectedNode.data.parameters)
? [`component-parameter-${name}`]
: [];
});
const invalidParameters = group.parameters.filter((name) => {
const definition = selectedDefinition.parameters[name];
if (!definition) {
return false;
}
const selectedUnit =
selectedNode.data.parameterUnits[name] ?? definition.unit ?? "";
const mediumDefinitionIndex =
isAmesimGasMediumDefinition(selectedDefinition) && name === "gi";
const gasReference =
!mediumDefinitionIndex &&
definition.editor === "amesimGasReference";
return Boolean(
componentParameterValidationMessage(
definition,
selectedNode.data.parameters[name] ?? "",
selectedUnit,
gasReference ? amesimGasMediumOptions : undefined,
),
);
});
const hiddenErrorCount = invalidParameters.filter((name) => {
const definition = selectedDefinition.parameters[name];
return (
definition &&
!parameterIsVisible(definition, selectedNode.data.parameters)
);
}).length;
return rowIds.length > 0 || invalidParameters.length > 0
? [
{
id: `${selectedNode.id}:${group.id}`,
label: group.label,
rowIds,
defaultExpanded: group.defaultExpanded,
errorCount: invalidParameters.length,
hiddenErrorCount,
},
]
: [];
})
: [];
const simulationSettingErrors = simulationConfigValidationMessages(
simulationConfig,
@@ -5825,6 +5957,7 @@ function FlowWorkbench() {
ariaLabel="组件参数"
availableWidth={parameterTableAvailableWidth}
columnLayout={parameterColumnLayout}
groups={componentParameterGroups}
onColumnLayoutChange={setParameterColumnLayout}
rows={componentParameterRows}
/>
@@ -5929,6 +6062,7 @@ function buildProjectPayload(
return {
name: name.trim() || "untitled",
mediumReferenceVersion: 1,
amesimParameterEncodingVersion: 1,
nodes: nodes.map((node) => ({
id: node.id,
type: "simulationComponent",
@@ -5961,6 +6095,110 @@ function buildProjectPayload(
};
}
function numericChoiceValue(value: ParameterValue | undefined) {
if (typeof value === "string" && value.trim() === "") {
return null;
}
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
}
function parameterIsVisible(
definition: ParameterDefinition,
parameters: Record<string, ParameterValue>,
) {
return (
!definition.visibleWhen?.length ||
definition.visibleWhen.every((condition) => {
const currentValue = numericChoiceValue(parameters[condition.parameter]);
return currentValue !== null && condition.values.includes(currentValue);
})
);
}
function normalizeParameterGroups(
value: unknown,
parameterNames: ReadonlySet<string>,
): ParameterGroupDefinition[] {
if (!Array.isArray(value)) {
return [];
}
const groupIds = new Set<string>();
const groupedParameters = new Set<string>();
const groups = value.flatMap((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const candidate = entry as Record<string, unknown>;
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
const label =
typeof candidate.label === "string" ? candidate.label.trim() : "";
if (!id || !label || groupIds.has(id) || !Array.isArray(candidate.parameters)) {
return [];
}
const parameters = candidate.parameters.flatMap((parameter) => {
if (
typeof parameter !== "string" ||
!parameterNames.has(parameter) ||
groupedParameters.has(parameter)
) {
return [];
}
groupedParameters.add(parameter);
return [parameter];
});
if (parameters.length === 0) {
return [];
}
groupIds.add(id);
const numericOrder = Number(candidate.order);
return [
{
id,
label,
order: Number.isInteger(numericOrder) ? numericOrder : 0,
parameters,
defaultExpanded: candidate.defaultExpanded === true,
},
];
});
return groups.sort(
(first, second) => first.order - second.order || first.id.localeCompare(second.id),
);
}
function normalizeParameterVisibilityConditions(
value: unknown,
): ParameterVisibilityCondition[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const conditions = value.flatMap((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const candidate = entry as Record<string, unknown>;
if (typeof candidate.parameter !== "string" || !candidate.parameter.trim()) {
return [];
}
if (!Array.isArray(candidate.values)) {
return [];
}
const values = [
...new Set(
candidate.values.flatMap((item) => {
const numericValue = Number(item);
return Number.isFinite(numericValue) ? [numericValue] : [];
}),
),
];
return values.length > 0
? [{ parameter: candidate.parameter.trim(), values }]
: [];
});
return conditions.length > 0 ? conditions : undefined;
}
function normalizeParameterScientificNotation(
value: unknown,
): Record<string, ParameterScientificNotation> {
@@ -5991,14 +6229,22 @@ function normalizeParameterScientificNotation(
function normalizeLoadedNode(
node: ProjectNodePayload,
componentDefinitions: ComponentDefinition[],
amesimParameterEncodingVersion?: 1 | null,
): SimulationNode {
const definition =
componentDefinitions.find((item) => item.type === node.data.componentType) ??
componentDefinitions.find((item) => item.modelType === node.data.modelType);
const effectiveModelType =
definition?.modelType || node.data.modelType || node.data.componentType;
const rawParameters = node.data.parameters ?? {};
const parameters = definition
const mergedParameters = definition
? { ...defaultParameters(definition), ...rawParameters }
: { ...rawParameters };
const parameters = migrateLegacyAmesimParameterEncoding(
effectiveModelType,
mergedParameters,
amesimParameterEncodingVersion,
);
if (
isAmesimGasMediumDefinition(definition) &&
!Object.prototype.hasOwnProperty.call(rawParameters, "gi")
@@ -6288,6 +6534,26 @@ function displayParameterValue(
return formatEditableNumber(displayValue);
}
function migrateLegacyAmesimParameterEncoding(
modelType: string,
parameters: Record<string, ParameterValue>,
version?: 1 | null,
) {
if (version === 1 || modelType !== "amesim_mecmas21") {
return { ...parameters };
}
const migrated = { ...parameters };
(["useFriction", "strib"] as const).forEach((name) => {
const value = numericChoiceValue(migrated[name]);
if (value === 0) {
migrated[name] = 1;
} else if (value === 1) {
migrated[name] = 2;
}
});
return migrated;
}
function formatEditableNumber(value: number) {
if (!Number.isFinite(value)) {
return "";
@@ -6496,6 +6762,24 @@ function parameterValidationMessage(
return "";
}
function componentParameterValidationMessage(
definition: ParameterDefinition,
value: ParameterValue,
selectedUnit: string,
gasMediumOptions?: readonly AmesimGasMediumOption[],
) {
if (gasMediumOptions !== undefined) {
const gasReferenceIndex = normalizeAmesimGasIndex(value);
if (
gasReferenceIndex === null ||
!gasMediumOptions.some((option) => option.value === gasReferenceIndex)
) {
return "引用的介质定义不存在";
}
}
return parameterValidationMessage(definition, value, selectedUnit);
}
function simulationCalculatedValueMetadata(
value: ParameterValue,
unit: string,
@@ -6951,7 +7235,11 @@ function parseProjectPayload(raw: string | null): ReactFlowProjectPayload | null
!project.simulation ||
typeof project.simulation !== "object" ||
(project.mediumReferenceVersion !== undefined &&
project.mediumReferenceVersion !== 1)
project.mediumReferenceVersion !== null &&
project.mediumReferenceVersion !== 1) ||
(project.amesimParameterEncodingVersion !== undefined &&
project.amesimParameterEncodingVersion !== null &&
project.amesimParameterEncodingVersion !== 1)
) {
return null;
}
@@ -6987,6 +7275,7 @@ function buildSystemXml(
system.setAttribute("name", project.name);
system.setAttribute("schemaVersion", "2");
system.setAttribute("mediumReferenceVersion", "1");
system.setAttribute("amesimParameterEncodingVersion", "1");
system.setAttribute("unitSystem", "SI");
const simulation = xmlDocument.createElement("Simulation");
+191 -58
View File
@@ -2,13 +2,13 @@ import {
Fragment,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import type {
CSSProperties,
FocusEvent as ReactFocusEvent,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
@@ -36,9 +36,19 @@ export type ParameterTableRow = {
calculatedValue?: string;
};
export type ParameterTableGroup = {
id: string;
label: string;
rowIds: readonly string[];
defaultExpanded?: boolean;
errorCount?: number;
hiddenErrorCount?: number;
};
type ParameterTableProps = {
ariaLabel: string;
rows: readonly ParameterTableRow[];
groups?: readonly ParameterTableGroup[];
columnLayout: ParameterColumnLayout;
availableWidth: number;
onColumnLayoutChange: (layout: ParameterColumnLayout) => void;
@@ -170,10 +180,12 @@ function helpPreviewPosition(clientX: number, clientY: number) {
export function ParameterTable({
ariaLabel,
rows,
groups = [],
columnLayout,
availableWidth,
onColumnLayoutChange,
}: ParameterTableProps) {
const groupDescriptionIdPrefix = useId().replace(/:/g, "");
const tableWrapRef = useRef<HTMLDivElement>(null);
const [measuredAvailableWidth, setMeasuredAvailableWidth] = useState<
number | null
@@ -188,6 +200,9 @@ export function ParameterTable({
const helpPreviewAnchorRef = useRef<HelpPreviewAnchor | null>(null);
const [activeColumnResize, setActiveColumnResize] =
useState<ColumnName | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>(
{},
);
const [helpPreview, setHelpPreview] = useState<HelpPreviewState | null>(null);
useLayoutEffect(() => {
@@ -219,6 +234,17 @@ export function ParameterTable({
setHelpPreview(null);
}, []);
const hasActiveParameterSelect = useCallback(() => {
if (typeof document === "undefined") {
return false;
}
const activeElement = document.activeElement;
return (
activeElement instanceof HTMLSelectElement &&
activeElement.closest(".parameter-table") !== null
);
}, []);
const scheduleHelpPreview = useCallback(
(
target: HTMLElement,
@@ -226,7 +252,7 @@ export function ParameterTable({
pointer?: { clientX: number; clientY: number },
) => {
dismissHelpPreview();
if (!rowHasHelpPreview(row)) {
if (hasActiveParameterSelect() || !rowHasHelpPreview(row)) {
return;
}
const bounds = target.getBoundingClientRect();
@@ -250,11 +276,19 @@ export function ParameterTable({
});
}, HELP_PREVIEW_DELAY_MS);
},
[dismissHelpPreview],
[dismissHelpPreview, hasActiveParameterSelect],
);
const dismissHelpOnSelectPointerDown = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.target instanceof HTMLSelectElement) {
dismissHelpPreview();
}
};
const trackHelpPointer = (
event: ReactMouseEvent<HTMLTableRowElement>,
event: ReactMouseEvent<HTMLTableCellElement>,
row: ParameterTableRow,
) => {
helpPreviewAnchorRef.current = {
@@ -278,6 +312,15 @@ export function ParameterTable({
};
}, [dismissHelpPreview]);
useEffect(() => {
if (
helpPreview &&
!rows.some((row) => row.id === helpPreview.rowId)
) {
dismissHelpPreview();
}
}, [dismissHelpPreview, helpPreview, rows]);
const changeColumnLayout = (
column: ColumnName,
boundaryDelta: number,
@@ -363,6 +406,25 @@ export function ParameterTable({
: null;
const previewDescription = previewRow?.description ||
(typeof previewRow?.help === "string" ? previewRow.help : "");
const rowsById = new Map(rows.map((row) => [row.id, row]));
const groupedRowIds = new Set<string>();
const resolvedGroups = groups.flatMap((group) => {
const groupRows = group.rowIds.flatMap((rowId) => {
if (groupedRowIds.has(rowId)) {
return [];
}
const row = rowsById.get(rowId);
if (!row) {
return [];
}
groupedRowIds.add(rowId);
return [row];
});
return groupRows.length > 0 || (group.errorCount ?? 0) > 0
? [{ ...group, rows: groupRows }]
: [];
});
const ungroupedRows = rows.filter((row) => !groupedRowIds.has(row.id));
const renderColumnResizer = (column: ColumnName) => {
const currentWidth =
@@ -396,16 +458,56 @@ export function ParameterTable({
);
};
const rowBlurred = (event: ReactFocusEvent<HTMLTableRowElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as globalThis.Node | null)) {
dismissHelpPreview();
}
};
const renderRows = (parameterRows: readonly ParameterTableRow[]) =>
parameterRows.map((row) => {
const hasUnit = row.unit !== undefined && row.unit !== null;
return (
<Fragment key={row.id}>
<tr
className={`parameter-table-row ${row.error ? "invalid" : ""} ${hasUnit ? "" : "unitless"} ${row.readOnly ? "read-only" : ""}`}
>
<th
aria-describedby={
helpPreview?.rowId === row.id
? "parameter-help-preview"
: undefined
}
className="parameter-table-cell parameter-table-name-cell"
onMouseEnter={(event) =>
scheduleHelpPreview(event.currentTarget, row, {
clientX: event.clientX,
clientY: event.clientY,
})
}
onMouseLeave={dismissHelpPreview}
onMouseMove={(event) => trackHelpPointer(event, row)}
scope="row"
>
{row.name}
</th>
<td className="parameter-table-cell parameter-table-value-cell">
{row.value}
</td>
<td className="parameter-table-cell parameter-table-unit-cell">
{hasUnit ? row.unit : <span aria-label="无单位">—</span>}
</td>
</tr>
{row.error ? (
<tr className="parameter-table-message-row">
<td className="parameter-table-cell" colSpan={3}>
<small className="field-error">{row.error}</small>
</td>
</tr>
) : null}
</Fragment>
);
});
return (
<div
className={`parameter-table-wrap ${activeColumnResize ? `resizing-${activeColumnResize}` : ""}`}
data-resizing-column={activeColumnResize ?? undefined}
onPointerDownCapture={dismissHelpOnSelectPointerDown}
ref={tableWrapRef}
>
<table aria-label={ariaLabel} className="parameter-table" style={tableStyle}>
@@ -429,57 +531,88 @@ export function ParameterTable({
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const hasPreview = rowHasHelpPreview(row);
const hasUnit = row.unit !== undefined && row.unit !== null;
return (
<Fragment key={row.id}>
<tr
aria-describedby={
helpPreview?.rowId === row.id
? "parameter-help-preview"
: undefined
}
className={`parameter-table-row ${row.error ? "invalid" : ""} ${hasUnit ? "" : "unitless"} ${row.readOnly ? "read-only" : ""}`}
onBlurCapture={rowBlurred}
onFocusCapture={(event) =>
scheduleHelpPreview(event.currentTarget, row)
}
onMouseEnter={(event) =>
scheduleHelpPreview(event.currentTarget, row, {
clientX: event.clientX,
clientY: event.clientY,
})
}
onMouseLeave={dismissHelpPreview}
onMouseMove={(event) => trackHelpPointer(event, row)}
tabIndex={row.readOnly && hasPreview ? 0 : undefined}
<tbody>{renderRows(ungroupedRows)}</tbody>
{resolvedGroups.map((group, groupIndex) => {
const expanded =
expandedGroups[group.id] ?? Boolean(group.defaultExpanded);
const errorCount =
group.errorCount ?? group.rows.filter((row) => Boolean(row.error)).length;
const hiddenErrorCount = Math.min(
errorCount,
Math.max(0, group.hiddenErrorCount ?? 0),
);
const errorDescriptionId = `${groupDescriptionIdPrefix}-group-${groupIndex}-error-description`;
const errorDescription = hiddenErrorCount > 0
? `${group.label}分组有 ${errorCount} 个参数错误,其中 ${hiddenErrorCount} 个参数当前被配置选项隐藏。`
: `${group.label}分组有 ${errorCount} 个参数错误。`;
return (
<tbody
className={`parameter-table-group${errorCount > 0 ? " invalid" : ""}`}
key={group.id}
>
<tr className="parameter-table-group-row">
<th
className="parameter-table-cell parameter-table-group-cell"
colSpan={3}
scope="rowgroup"
>
<th
className="parameter-table-cell parameter-table-name-cell"
scope="row"
<button
aria-describedby={errorCount > 0 ? errorDescriptionId : undefined}
aria-expanded={expanded}
className="parameter-table-group-toggle"
onClick={() => {
dismissHelpPreview();
setExpandedGroups((current) => ({
...current,
[group.id]: !expanded,
}));
}}
type="button"
>
{row.name}
</th>
<td className="parameter-table-cell parameter-table-value-cell">
{row.value}
</td>
<td className="parameter-table-cell parameter-table-unit-cell">
{hasUnit ? row.unit : <span aria-label="无单位">—</span>}
</td>
</tr>
{row.error ? (
<tr className="parameter-table-message-row">
<td className="parameter-table-cell" colSpan={3}>
<small className="field-error">{row.error}</small>
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
<span
aria-hidden="true"
className="parameter-table-group-chevron"
>
{expanded ? "▾" : "▸"}
</span>
<span
aria-hidden="true"
className="parameter-table-group-folder"
/>
<span className="parameter-table-group-label">
{group.label}
</span>
{errorCount > 0 ? (
<span aria-hidden="true" className="parameter-table-group-error">
{errorCount} 个错误
{hiddenErrorCount > 0
? `(${hiddenErrorCount} 个隐藏)`
: ""}
</span>
) : null}
</button>
{errorCount > 0 ? (
<span className="visually-hidden" id={errorDescriptionId}>
{errorDescription}
</span>
) : null}
</th>
</tr>
{expanded ? (
<>
{renderRows(group.rows)}
{hiddenErrorCount > 0 ? (
<tr className="parameter-table-group-hidden-error-row">
<td className="parameter-table-cell" colSpan={3}>
其中 {hiddenErrorCount} 个错误参数被当前配置选项隐藏,请调整上方配置后修改。
</td>
</tr>
) : null}
</>
) : null}
</tbody>
);
})}
</table>
{helpPreview && previewRow && typeof document !== "undefined"
? createPortal(
+108 -1
View File
@@ -528,6 +528,113 @@ textarea {
height: 28px;
}
.parameter-table-group-row {
height: 30px;
}
.parameter-table .parameter-table-group-cell {
height: 30px;
padding: 0;
border-right: 0;
background: #eceeef;
}
.parameter-table-group-toggle {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
min-width: 0;
height: 30px;
box-sizing: border-box;
padding: 0 8px;
overflow: hidden;
border: 0;
outline: 0;
color: #2f3c48;
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
}
.parameter-table-group-toggle:hover {
background: #e0e7ec;
}
.parameter-table-group-toggle:focus-visible {
outline: 2px solid rgba(29, 111, 184, 0.52);
outline-offset: -2px;
background: #deedf5;
}
.parameter-table-group-chevron {
flex: 0 0 10px;
width: 10px;
color: #364451;
font-size: 11px;
line-height: 1;
text-align: center;
}
.parameter-table-group-folder {
position: relative;
flex: 0 0 14px;
width: 14px;
height: 10px;
box-sizing: border-box;
border: 1px solid #65717b;
background: #f7f8f9;
}
.parameter-table-group-folder::before {
position: absolute;
top: -4px;
left: -1px;
width: 6px;
height: 4px;
box-sizing: border-box;
border: 1px solid #65717b;
border-bottom: 0;
background: #f7f8f9;
content: "";
}
.parameter-table-group-label {
min-width: 0;
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.parameter-table-group-error {
flex: 0 0 auto;
margin-left: auto;
color: #b42318;
font-size: 11px;
font-weight: 600;
}
.parameter-table-group.invalid .parameter-table-group-cell {
background: #fff1ef;
}
.parameter-table-group.invalid .parameter-table-group-toggle:hover {
background: #ffe5e1;
}
.parameter-table-group-hidden-error-row > .parameter-table-cell {
height: auto;
min-height: 28px;
padding: 6px 8px;
overflow: visible;
color: #9f2018;
background: #fff8f7;
line-height: 1.45;
white-space: normal;
}
.parameter-table-cell {
min-width: 0;
height: 32px;
@@ -723,7 +830,7 @@ textarea {
background: #fff8f7;
}
.parameter-table > tbody > tr:last-child > .parameter-table-cell {
.parameter-table > tbody:last-of-type > tr:last-child > .parameter-table-cell {
border-bottom: 0;
}
+233
View File
@@ -186,6 +186,239 @@ export const componentCatalog = {
},
],
},
{
type: "choice_visibility_test",
label: "离散参数测试元件",
modelType: "choice_visibility_test",
modelVersion: "1.0.0",
symbol: "symbol-not-registered",
order: 5,
category: {
id: "generic",
label: "通用组件",
order: 1,
},
ports: [],
parameters: [
{
name: "mode",
label: "工作模式",
quantity: "dimensionless",
unit: "",
default: 1,
minimum: 1,
maximum: 3,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "基础模式" },
{ value: 2, label: "高级模式" },
{ value: 3, label: "专家模式" },
],
},
{
name: "variant",
label: "高级变体",
quantity: "dimensionless",
unit: "",
default: 10,
minimum: 10,
maximum: 20,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 10, label: "变体 A" },
{ value: 20, label: "变体 B" },
],
},
{
name: "basic_value",
label: "基础参数",
quantity: "dimensionless",
unit: "",
default: 1,
minimumExclusive: false,
visibleWhen: [{ parameter: "mode", values: [1] }],
},
{
name: "advanced_value",
label: "高级参数",
quantity: "dimensionless",
unit: "",
default: 1,
minimumExclusive: false,
visibleWhen: [{ parameter: "mode", values: [2, 3] }],
},
{
name: "combined_value",
label: "组合参数",
quantity: "dimensionless",
unit: "",
default: 3,
minimumExclusive: false,
visibleWhen: [
{ parameter: "mode", values: [2, 3] },
{ parameter: "variant", values: [20] },
],
},
],
},
{
type: "amesim_mecmas21",
label: "MECMAS21 编码测试元件",
modelType: "amesim_mecmas21",
modelVersion: "1.0.0",
symbol: "symbol-not-registered",
order: 6,
category: {
id: "generic",
label: "通用组件",
order: 1,
},
ports: [],
parameterGroups: [
{
id: "friction",
label: "摩擦",
order: 10,
parameters: ["frictionType", "strib", "rvisc"],
defaultExpanded: false,
},
{
id: "endstops",
label: "限位",
order: 20,
parameters: ["discContactOption", "xmin"],
defaultExpanded: false,
},
],
parameters: [
{
name: "mass",
label: "质量",
quantity: "mass",
unit: "kg",
default: 1,
minimum: 0,
minimumExclusive: true,
},
{
name: "rvisc",
label: "黏性摩擦系数",
quantity: "translational_damping",
unit: "N/(m/s)",
default: 0,
minimum: 0,
minimumExclusive: false,
description: "用于验证折叠摩擦参数仍支持详情提示和值保留。",
visibleWhen: [{ parameter: "useFriction", values: [2] }],
},
{
name: "xmin",
label: "下位移限位",
quantity: "length",
unit: "m",
default: -1,
minimumExclusive: false,
visibleWhen: [{ parameter: "stoptype", values: [1, 2, 3] }],
},
{
name: "useFriction",
label: "启用摩擦",
quantity: "dimensionless",
unit: "",
default: 1,
minimum: 1,
maximum: 2,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "禁用" },
{ value: 2, label: "启用" },
],
},
{
name: "stoptype",
label: "限位类型",
quantity: "dimensionless",
unit: "",
default: 4,
minimum: 1,
maximum: 4,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "理想限位" },
{ value: 2, label: "弹性限位" },
{ value: 3, label: "恢复碰撞" },
{ value: 4, label: "无限位" },
],
},
{
name: "discContactOption",
label: "允许负接触力",
quantity: "dimensionless",
unit: "",
default: 1,
minimum: 1,
maximum: 2,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "允许负接触力" },
{ value: 2, label: "不允许负接触力" },
],
visibleWhen: [{ parameter: "stoptype", values: [2] }],
},
{
name: "strib",
label: "Stribeck 选项",
quantity: "dimensionless",
unit: "",
default: 1,
minimum: 1,
maximum: 2,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "禁用" },
{ value: 2, label: "启用" },
],
visibleWhen: [
{ parameter: "useFriction", values: [2] },
{ parameter: "stoptype", values: [1, 2, 4] },
{ parameter: "frictionType", values: [2] },
],
},
{
name: "frictionType",
label: "摩擦类型",
quantity: "dimensionless",
unit: "",
default: 1,
minimum: 1,
maximum: 2,
minimumExclusive: false,
editor: "choice",
options: [
{ value: 1, label: "简单" },
{ value: 2, label: "高级" },
],
visibleWhen: [
{ parameter: "useFriction", values: [2] },
{ parameter: "stoptype", values: [1, 2, 4] },
],
},
{
name: "unrelatedCode",
label: "无关编码",
quantity: "dimensionless",
unit: "",
default: 7,
minimumExclusive: false,
},
],
},
],
},
],
+501 -9
View File
@@ -7,7 +7,11 @@ test.beforeEach(async ({ page }) => {
});
function parameterRow(table: Locator, name: string) {
return table.getByRole("rowheader", { name, exact: true }).locator("..");
return parameterNameCell(table, name).locator("..");
}
function parameterNameCell(table: Locator, name: string) {
return table.getByRole("rowheader", { name, exact: true });
}
async function addPneumaticTestComponent(page: Page) {
@@ -22,6 +26,30 @@ async function addPneumaticTestComponent(page: Page) {
await node.click();
}
async function addChoiceVisibilityTestComponent(page: Page) {
const canvas = page.locator(".flow-canvas .react-flow__pane");
await page
.getByRole("button", { name: "离散参数测试元件", exact: true })
.dragTo(canvas, { targetPosition: { x: 620, y: 330 } });
const node = page.locator(
'.flow-canvas .react-flow__node[data-id="choice_visibility_test_1"]',
);
await expect(node).toBeVisible();
await node.click();
}
async function addMecmasEncodingTestComponent(page: Page) {
const canvas = page.locator(".flow-canvas .react-flow__pane");
await page
.getByRole("button", { name: "MECMAS21 编码测试元件", exact: true })
.dragTo(canvas, { targetPosition: { x: 620, y: 330 } });
const node = page.locator(
'.flow-canvas .react-flow__node[data-id="amesim_mecmas21_1"]',
);
await expect(node).toBeVisible();
await node.click();
}
test("组件参数和仿真设置使用共享的三列表格", async ({ page }) => {
await page.goto("/");
await addPneumaticTestComponent(page);
@@ -170,6 +198,456 @@ test("组件参数和仿真设置使用共享的三列表格", async ({ page })
await expect(solver).toHaveValue("Radau");
});
test("通用离散选项按多条件控制参数行且保留隐藏值和未知旧值", async ({
page,
}) => {
await page.goto("/");
await addChoiceVisibilityTestComponent(page);
const componentTable = page.getByRole("table", { name: "组件参数" });
const mode = page.getByLabel("工作模式", { exact: true });
const variant = page.getByLabel("高级变体", { exact: true });
await expect(mode).toHaveValue("1");
await expect(mode.locator("option:checked")).toHaveText("基础模式");
await expect(variant).toHaveValue("10");
await expect(parameterRow(componentTable, "基础参数")).toBeVisible();
await expect(parameterRow(componentTable, "高级参数")).toHaveCount(0);
await expect(parameterRow(componentTable, "组合参数")).toHaveCount(0);
await mode.selectOption("2");
await expect(parameterRow(componentTable, "基础参数")).toHaveCount(0);
await expect(parameterRow(componentTable, "高级参数")).toBeVisible();
await expect(parameterRow(componentTable, "组合参数")).toHaveCount(0);
const advancedValue = page.getByLabel("高级参数", { exact: true });
await advancedValue.fill("42");
await advancedValue.press("Enter");
await variant.selectOption("20");
await expect(parameterRow(componentTable, "组合参数")).toBeVisible();
await mode.selectOption("3");
await expect(parameterRow(componentTable, "高级参数")).toBeVisible();
await expect(parameterRow(componentTable, "组合参数")).toBeVisible();
await mode.selectOption("1");
await expect(parameterRow(componentTable, "高级参数")).toHaveCount(0);
await expect(parameterRow(componentTable, "组合参数")).toHaveCount(0);
await mode.selectOption("2");
await expect(page.getByLabel("高级参数", { exact: true })).toHaveValue("42");
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const changedProjectCount = await page.evaluate(() => {
let changed = 0;
Object.keys(window.localStorage)
.filter((key) =>
[
"system-simulation-flow:autosave",
"system-simulation-flow:project:",
].some((prefix) => key.startsWith(prefix)),
)
.forEach((key) => {
const raw = window.localStorage.getItem(key);
if (!raw) {
return;
}
const project = JSON.parse(raw) as {
nodes?: Array<{
id?: string;
data?: { parameters?: Record<string, number | string> };
}>;
};
const node = project.nodes?.find(
(candidate) => candidate.id === "choice_visibility_test_1",
);
if (!node?.data?.parameters) {
return;
}
node.data.parameters.mode = 99;
window.localStorage.setItem(key, JSON.stringify(project));
changed += 1;
});
return changed;
});
expect(changedProjectCount).toBeGreaterThanOrEqual(2);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await page
.locator('.flow-canvas .react-flow__node[data-id="choice_visibility_test_1"]')
.click();
await expect(mode).toHaveValue("");
await expect(mode).toHaveAttribute("aria-invalid", "true");
await expect(mode.locator("option:checked")).toHaveText(
"当前选项无效,请重新选择",
);
await expect(parameterRow(componentTable, "基础参数")).toHaveCount(0);
await expect(parameterRow(componentTable, "高级参数")).toHaveCount(0);
await expect(parameterRow(componentTable, "组合参数")).toHaveCount(0);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const persistedModes = await page.evaluate(() =>
Object.keys(window.localStorage)
.filter((key) => key.startsWith("system-simulation-flow:project:"))
.flatMap((key) => {
const raw = window.localStorage.getItem(key);
if (!raw) {
return [];
}
const project = JSON.parse(raw) as {
nodes?: Array<{
id?: string;
data?: { parameters?: Record<string, number | string> };
}>;
};
const node = project.nodes?.find(
(candidate) => candidate.id === "choice_visibility_test_1",
);
return node?.data?.parameters ? [node.data.parameters.mode] : [];
}),
);
expect(persistedModes).toContain(99);
await mode.selectOption("2");
await expect(mode).toHaveValue("2");
await expect(mode).not.toHaveAttribute("aria-invalid", "true");
await expect(page.getByLabel("高级参数", { exact: true })).toHaveValue("42");
});
test("MECMAS21 可配置参数前置并按条件显示可折叠参数组", async ({
page,
}) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
const componentTable = page.getByRole("table", { name: "组件参数" });
const useFriction = page.getByLabel("启用摩擦", { exact: true });
const stoptype = page.getByLabel("限位类型", { exact: true });
const frictionGroup = componentTable.getByRole("button", { name: /摩擦/ });
const endstopsGroup = componentTable.getByRole("button", { name: /限位/ });
const parameterOrder = (
await componentTable.getByRole("rowheader").allTextContents()
).map((text) => text.trim());
expect(parameterOrder.indexOf("启用摩擦")).toBeGreaterThanOrEqual(0);
expect(parameterOrder.indexOf("限位类型")).toBeGreaterThanOrEqual(0);
expect(parameterOrder.indexOf("质量")).toBeGreaterThanOrEqual(0);
expect(parameterOrder.indexOf("启用摩擦")).toBeLessThan(
parameterOrder.indexOf("质量"),
);
expect(parameterOrder.indexOf("限位类型")).toBeLessThan(
parameterOrder.indexOf("质量"),
);
await expect(useFriction).toHaveValue("1");
await expect(stoptype).toHaveValue("4");
await expect(frictionGroup).toHaveCount(0);
await expect(endstopsGroup).toHaveCount(0);
await useFriction.selectOption("2");
await expect(frictionGroup).toBeVisible();
await expect(frictionGroup).toHaveAttribute("aria-expanded", "false");
await expect(frictionGroup.locator("xpath=ancestor::th")).toHaveAttribute(
"colspan",
"3",
);
await expect(page.getByLabel("摩擦类型", { exact: true })).toBeHidden();
const frictionGroupBox = await frictionGroup.boundingBox();
const unrelatedRowBox = await parameterRow(
componentTable,
"无关编码",
).boundingBox();
expect(frictionGroupBox).not.toBeNull();
expect(unrelatedRowBox).not.toBeNull();
expect(frictionGroupBox!.y).toBeGreaterThan(unrelatedRowBox!.y);
await frictionGroup.focus();
await expect(frictionGroup).toBeFocused();
await page.keyboard.press("Enter");
await expect(frictionGroup).toHaveAttribute("aria-expanded", "true");
const frictionType = page.getByLabel("摩擦类型", { exact: true });
const strib = page.getByLabel("Stribeck 选项", { exact: true });
const viscousFriction = page.getByLabel("黏性摩擦系数", { exact: true });
await expect(frictionType).toBeVisible();
await expect(viscousFriction).toBeVisible();
await expect(strib).toHaveCount(0);
await frictionType.selectOption("2");
await expect(strib).toBeVisible();
const expandedOrder = (
await componentTable.getByRole("rowheader").allTextContents()
).map((text) => text.trim());
expect(expandedOrder.indexOf("摩擦类型")).toBeLessThan(
expandedOrder.indexOf("Stribeck 选项"),
);
expect(expandedOrder.indexOf("Stribeck 选项")).toBeLessThan(
expandedOrder.indexOf("黏性摩擦系数"),
);
await frictionGroup.focus();
await expect(frictionGroup).toBeFocused();
await page.keyboard.press("Space");
await expect(frictionGroup).toHaveAttribute("aria-expanded", "false");
await expect(frictionType).toBeHidden();
await expect(strib).toBeHidden();
await expect(viscousFriction).toBeHidden();
await page.keyboard.press("Space");
await expect(frictionGroup).toHaveAttribute("aria-expanded", "true");
await expect(frictionType).toHaveValue("2");
await stoptype.selectOption("2");
await expect(endstopsGroup).toBeVisible();
await expect(endstopsGroup).toHaveAttribute("aria-expanded", "false");
await expect(endstopsGroup.locator("xpath=ancestor::th")).toHaveAttribute(
"colspan",
"3",
);
await endstopsGroup.focus();
await page.keyboard.press("Enter");
await expect(endstopsGroup).toHaveAttribute("aria-expanded", "true");
await expect(page.getByLabel("允许负接触力", { exact: true })).toBeVisible();
await expect(page.getByLabel("下位移限位", { exact: true })).toBeVisible();
const endstopOrder = (
await componentTable.getByRole("rowheader").allTextContents()
).map((text) => text.trim());
expect(endstopOrder.indexOf("允许负接触力")).toBeLessThan(
endstopOrder.indexOf("下位移限位"),
);
const endstopsGroupBox = await endstopsGroup.boundingBox();
expect(endstopsGroupBox).not.toBeNull();
expect(frictionGroupBox!.y).toBeLessThan(endstopsGroupBox!.y);
await stoptype.selectOption("3");
await expect(frictionGroup).toBeVisible();
await expect(endstopsGroup).toBeVisible();
await expect(frictionType).toHaveCount(0);
await expect(strib).toHaveCount(0);
await expect(viscousFriction).toBeVisible();
await expect(page.getByLabel("允许负接触力", { exact: true })).toHaveCount(0);
await expect(page.getByLabel("下位移限位", { exact: true })).toBeVisible();
await stoptype.selectOption("4");
await expect(endstopsGroup).toHaveCount(0);
await expect(frictionType).toBeVisible();
});
test("MECMAS21 参数组折叠和条件切换保留值并清理详情浮层", async ({
page,
}) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
const componentTable = page.getByRole("table", { name: "组件参数" });
const useFriction = page.getByLabel("启用摩擦", { exact: true });
const frictionGroup = componentTable.getByRole("button", { name: /摩擦/ });
await useFriction.selectOption("2");
await frictionGroup.click();
const frictionType = page.getByLabel("摩擦类型", { exact: true });
await frictionType.selectOption("2");
const strib = page.getByLabel("Stribeck 选项", { exact: true });
const viscousFriction = page.getByLabel("黏性摩擦系数", { exact: true });
await strib.selectOption("2");
await viscousFriction.fill("12.5");
await viscousFriction.press("Enter");
await expect(viscousFriction).toHaveValue("12.5");
await frictionGroup.focus();
await parameterNameCell(componentTable, "黏性摩擦系数").hover();
await page.waitForTimeout(600);
const preview = page.locator("body > #parameter-help-preview");
await expect(preview.locator(".parameter-help-preview-title")).toHaveText(
"黏性摩擦系数",
);
await page.keyboard.press("Enter");
await expect(frictionGroup).toHaveAttribute("aria-expanded", "false");
await expect(preview).toHaveCount(0);
await frictionGroup.click();
await expect(frictionType).toHaveValue("2");
await expect(strib).toHaveValue("2");
await expect(viscousFriction).toHaveValue("12.5");
await useFriction.selectOption("1");
await expect(frictionGroup).toHaveCount(0);
await useFriction.selectOption("2");
await expect(frictionGroup).toBeVisible();
if ((await frictionGroup.getAttribute("aria-expanded")) !== "true") {
await frictionGroup.click();
}
await expect(frictionType).toHaveValue("2");
await expect(strib).toHaveValue("2");
await expect(viscousFriction).toHaveValue("12.5");
await viscousFriction.fill("-1");
await viscousFriction.press("Enter");
await expect(componentTable.getByText("不能小于 0", { exact: true })).toBeVisible();
await frictionGroup.click();
await expect(frictionGroup).toHaveAttribute("aria-expanded", "false");
await expect(frictionGroup).toContainText("1 个错误");
await useFriction.selectOption("1");
await expect(frictionGroup).toBeVisible();
await expect(frictionGroup).toContainText("1 个错误");
await expect(frictionGroup).toHaveAttribute("aria-describedby");
await frictionGroup.click();
await expect(componentTable).toContainText(
"1 个错误参数被当前配置选项隐藏,请调整上方配置后修改",
);
});
test("MECMAS21 旧参数编码只迁移一次且新工程和 XML 写入版本", async ({
page,
}) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
const useFriction = page.getByLabel("启用摩擦", { exact: true });
const frictionGroup = page
.getByRole("table", { name: "组件参数" })
.getByRole("button", { name: /摩擦/ });
await expect(useFriction).toHaveValue("1");
await expect(frictionGroup).toHaveCount(0);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const savedEncodingVersions = await page.evaluate(() =>
Object.keys(window.localStorage)
.filter((key) => key.startsWith("system-simulation-flow:project:"))
.flatMap((key) => {
const raw = window.localStorage.getItem(key);
if (!raw) {
return [];
}
const project = JSON.parse(raw) as {
amesimParameterEncodingVersion?: number | null;
nodes?: Array<{ id?: string }>;
};
return project.nodes?.some(
(node) => node.id === "amesim_mecmas21_1",
)
? [project.amesimParameterEncodingVersion]
: [];
}),
);
expect(savedEncodingVersions).toContain(1);
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expect(page.getByLabel("生成的系统 XML")).toContainText(
'amesimParameterEncodingVersion="1"',
);
const migratedProjectCount = await page.evaluate(() => {
let changed = 0;
Object.keys(window.localStorage)
.filter((key) =>
[
"system-simulation-flow:autosave",
"system-simulation-flow:project:",
].some((prefix) => key.startsWith(prefix)),
)
.forEach((key) => {
const raw = window.localStorage.getItem(key);
if (!raw) {
return;
}
const project = JSON.parse(raw) as {
mediumReferenceVersion?: number | null;
amesimParameterEncodingVersion?: number | null;
nodes?: Array<{
id?: string;
data?: {
modelType?: string;
parameters?: Record<string, number | string>;
};
}>;
};
const node = project.nodes?.find(
(candidate) => candidate.id === "amesim_mecmas21_1",
);
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);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await page
.locator('.flow-canvas .react-flow__node[data-id="amesim_mecmas21_1"]')
.click();
await expect(useFriction).toHaveValue("1");
await expect(useFriction.locator("option:checked")).toHaveText("禁用");
await useFriction.selectOption("2");
await expect(frictionGroup).toHaveAttribute("aria-expanded", "false");
await frictionGroup.click();
const frictionType = page.getByLabel("摩擦类型", { exact: true });
await frictionType.selectOption("2");
const strib = page.getByLabel("Stribeck 选项", { exact: true });
await expect(strib).toHaveValue("2");
await expect(strib.locator("option:checked")).toHaveText("启用");
await expect(page.getByLabel("无关编码", { exact: true })).toHaveValue("9");
await useFriction.selectOption("1");
await expect(frictionGroup).toHaveCount(0);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await page
.locator('.flow-canvas .react-flow__node[data-id="amesim_mecmas21_1"]')
.click();
await expect(useFriction).toHaveValue("1");
await expect(page.getByLabel("无关编码", { exact: true })).toHaveValue("9");
await useFriction.selectOption("2");
await expect(frictionGroup).toBeVisible();
if ((await frictionGroup.getAttribute("aria-expanded")) !== "true") {
await frictionGroup.click();
}
await expect(frictionType).toHaveValue("2");
await expect(strib).toHaveValue("2");
});
test("详情浮层只在参数列悬停且下拉选择期间不触发", async ({ page }) => {
await page.goto("/");
await addMecmasEncodingTestComponent(page);
const componentTable = page.getByRole("table", { name: "组件参数" });
const choice = page.getByLabel("启用摩擦", { exact: true });
const otherNameCell = parameterNameCell(componentTable, "无关编码");
const preview = page.locator("body > #parameter-help-preview");
await otherNameCell.hover();
await page.waitForTimeout(600);
await expect(preview).toBeVisible();
await expect(preview.locator(".parameter-help-preview-title")).toHaveText(
"无关编码",
);
await choice.hover();
await expect(preview).toHaveCount(0);
await choice.focus();
await expect(choice).toBeFocused();
await otherNameCell.hover();
await page.waitForTimeout(600);
await expect(preview).toHaveCount(0);
await choice.selectOption("2");
await choice.focus();
await otherNameCell.hover();
await page.waitForTimeout(600);
await expect(preview).toHaveCount(0);
await choice.evaluate((element) => element.blur());
await page.locator(".panel-title").filter({ hasText: "组件参数" }).hover();
await otherNameCell.hover();
await page.waitForTimeout(600);
await expect(preview.locator(".parameter-help-preview-title")).toHaveText(
"无关编码",
);
});
test("最窄参数面板滚动时表头固定且值列保持可用宽度", async ({ page }) => {
await page.goto("/");
await addPneumaticTestComponent(page);
@@ -234,19 +712,31 @@ test("最窄参数面板滚动时表头固定且值列保持可用宽度", async
expect(narrowLayout.horizontalOverflow).toBeLessThanOrEqual(1);
});
test("参数说明延迟显示,单位切换不改变 XML 基础值", async ({ page }) => {
test("参数说明只在参数列悬停显示,单位切换不改变 XML 基础值", async ({
page,
}) => {
await page.goto("/");
await addPneumaticTestComponent(page);
const componentTable = page.getByRole("table", { name: "组件参数" });
const pressureRow = parameterRow(componentTable, "参考压力");
const pressureNameCell = parameterNameCell(componentTable, "参考压力");
const pressureValueCell = pressureRow.locator(".parameter-table-value-cell");
const pressureUnitCell = pressureRow.locator(".parameter-table-unit-cell");
const preview = page.locator("body > #parameter-help-preview");
const pressureRowBox = await pressureRow.boundingBox();
expect(pressureRowBox).not.toBeNull();
await pressureValueCell.hover();
await page.waitForTimeout(600);
await expect(preview).toHaveCount(0);
await pressureUnitCell.hover();
await page.waitForTimeout(600);
await expect(preview).toHaveCount(0);
const pressureNameCellBox = await pressureNameCell.boundingBox();
expect(pressureNameCellBox).not.toBeNull();
const pointer = {
x: pressureRowBox!.x + pressureRowBox!.width / 2,
y: pressureRowBox!.y + pressureRowBox!.height / 2,
x: pressureNameCellBox!.x + pressureNameCellBox!.width / 2,
y: pressureNameCellBox!.y + pressureNameCellBox!.height / 2,
};
await page.mouse.move(pointer.x, pointer.y);
await page.waitForTimeout(600);
@@ -283,13 +773,15 @@ test("参数说明延迟显示,单位切换不改变 XML 基础值", async ({
expect(Math.abs(stationaryPreviewBox!.x - previewBox!.x)).toBeLessThanOrEqual(1);
expect(Math.abs(stationaryPreviewBox!.y - previewBox!.y)).toBeLessThanOrEqual(1);
await pressureValueCell.hover();
await expect(preview).toHaveCount(0);
await page.locator(".panel-title").filter({ hasText: "组件参数" }).hover();
await expect(preview).toHaveCount(0);
await page.getByLabel("参考压力单位").selectOption("kPa");
await expect(page.getByLabel("参考压力", { exact: true })).toHaveValue("100");
await pressureRow.hover();
await pressureNameCell.hover();
await page.waitForTimeout(600);
await expect(preview.locator(".parameter-help-preview-meta")).toContainText(
"默认值:100",
@@ -527,7 +1019,7 @@ test("组件参数公式保留原式,并在说明中显示计算值且 XML 只
await expect(pressureInput).toHaveValue(expression);
await expect(pressureRow).not.toContainText("78.5");
await pressureRow.hover();
await parameterNameCell(componentTable, "参考压力").hover();
await page.waitForTimeout(600);
const preview = page.locator("body > #parameter-help-preview");
await expect(preview).toBeVisible();
@@ -602,7 +1094,7 @@ test("仿真数值设置同样保留公式,并在执行协议中使用计算
await expect(stopInput).toHaveValue(expression);
await expect(stopRow).not.toContainText("7");
await stopRow.hover();
await parameterNameCell(simulationTable, "结束时间").hover();
await page.waitForTimeout(600);
await expect(
page.getByTestId("parameter-calculated-value"),